Skip to content

Commit f036177

Browse files
committed
feat: better parseMulti
1 parent f356a2b commit f036177

File tree

2 files changed

+285
-30
lines changed

2 files changed

+285
-30
lines changed
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import {parseMultipart} from '../parseMultipart';
2+
3+
describe('parseMultipart', () => {
4+
const boundary = 'test-boundary';
5+
const CRLF = '\r\n';
6+
7+
it('should parse a complete chunk with JSON content', () => {
8+
const chunk = {
9+
type: 'metrics',
10+
data: {value: 42},
11+
};
12+
const responseText = [
13+
`--${boundary}${CRLF}`,
14+
'Content-Type: application/json\r\n',
15+
`Content-Length: ${JSON.stringify(chunk).length}\r\n`,
16+
'\r\n',
17+
JSON.stringify(chunk),
18+
].join('');
19+
20+
const result = parseMultipart({
21+
responseText,
22+
lastProcessedLength: 0,
23+
boundary,
24+
});
25+
26+
expect(result.chunks).toHaveLength(1);
27+
expect(result.chunks[0]).toEqual(chunk);
28+
expect(result.lastProcessedLength).toBe(responseText.length);
29+
});
30+
31+
it('headers can be in any order', () => {
32+
const chunk = {
33+
type: 'metrics',
34+
data: {value: 42},
35+
};
36+
const responseText = [
37+
`--${boundary}${CRLF}`,
38+
`Content-Length: ${JSON.stringify(chunk).length}\r\n`,
39+
'Content-Type: application/json\r\n',
40+
'\r\n',
41+
JSON.stringify(chunk),
42+
].join('');
43+
44+
const result = parseMultipart({
45+
responseText,
46+
lastProcessedLength: 0,
47+
boundary,
48+
});
49+
50+
expect(result.chunks).toHaveLength(1);
51+
expect(result.chunks[0]).toEqual(chunk);
52+
expect(result.lastProcessedLength).toBe(responseText.length);
53+
});
54+
55+
it('should parse multiple complete chunks', () => {
56+
const chunk1 = {type: 'metrics', data: {value: 1}};
57+
const chunk2 = {type: 'metrics', data: {value: 2}};
58+
59+
const createChunkText = (chunk: any) =>
60+
[
61+
`--${boundary}${CRLF}`,
62+
'Content-Type: application/json\r\n',
63+
`Content-Length: ${JSON.stringify(chunk).length}\r\n`,
64+
'\r\n',
65+
JSON.stringify(chunk),
66+
].join('');
67+
68+
const responseText = createChunkText(chunk1) + createChunkText(chunk2);
69+
70+
const result = parseMultipart({
71+
responseText,
72+
lastProcessedLength: 0,
73+
boundary,
74+
});
75+
76+
expect(result.chunks).toHaveLength(2);
77+
expect(result.chunks[0]).toEqual(chunk1);
78+
expect(result.chunks[1]).toEqual(chunk2);
79+
expect(result.lastProcessedLength).toBe(responseText.length);
80+
});
81+
82+
it('should handle incomplete headers', () => {
83+
const responseText = [
84+
`--${boundary}${CRLF}`,
85+
'Content-Type: application/json\r\n',
86+
// Missing Content-Length and content
87+
].join('');
88+
89+
const result = parseMultipart({
90+
responseText,
91+
lastProcessedLength: 0,
92+
boundary,
93+
});
94+
95+
expect(result.chunks).toHaveLength(0);
96+
expect(result.lastProcessedLength).toBe(0);
97+
});
98+
99+
it('should handle incomplete content', () => {
100+
const chunk = {type: 'metrics', data: {value: 42}};
101+
const fullContent = JSON.stringify(chunk);
102+
const partialContent = fullContent.slice(0, 5); // Incomplete JSON
103+
104+
const responseText = [
105+
`--${boundary}${CRLF}`,
106+
'Content-Type: application/json\r\n',
107+
`Content-Length: ${fullContent.length}\r\n`,
108+
'\r\n',
109+
partialContent,
110+
].join('');
111+
112+
const result = parseMultipart({
113+
responseText,
114+
lastProcessedLength: 0,
115+
boundary,
116+
});
117+
118+
expect(result.chunks).toHaveLength(0);
119+
expect(result.lastProcessedLength).toBe(0);
120+
});
121+
122+
it('should handle invalid JSON content', () => {
123+
const invalidJson = '{invalid:json}';
124+
const responseText = [
125+
`--${boundary}${CRLF}`,
126+
'Content-Type: application/json\r\n',
127+
`Content-Length: ${invalidJson.length}\r\n`,
128+
'\r\n',
129+
invalidJson,
130+
].join('');
131+
132+
const result = parseMultipart({
133+
responseText,
134+
lastProcessedLength: 0,
135+
boundary,
136+
});
137+
138+
expect(result.chunks).toHaveLength(0);
139+
expect(result.lastProcessedLength).toBe(responseText.length);
140+
});
141+
142+
it('should continue parsing from lastProcessedLength', () => {
143+
const chunk1 = {type: 'metrics', data: {value: 1}};
144+
const chunk2 = {type: 'metrics', data: {value: 2}};
145+
146+
const createChunkText = (chunk: any) =>
147+
[
148+
`--${boundary}${CRLF}`,
149+
'Content-Type: application/json\r\n',
150+
`Content-Length: ${JSON.stringify(chunk).length}\r\n`,
151+
'\r\n',
152+
JSON.stringify(chunk),
153+
].join('');
154+
155+
const chunk1Text = createChunkText(chunk1);
156+
const chunk2Text = createChunkText(chunk2);
157+
const responseText = chunk1Text + chunk2Text;
158+
159+
// First parse
160+
const result1 = parseMultipart({
161+
responseText,
162+
lastProcessedLength: 0,
163+
boundary,
164+
});
165+
166+
expect(result1.chunks).toHaveLength(2);
167+
expect(result1.lastProcessedLength).toBe(responseText.length);
168+
169+
// Parse with existing lastProcessedLength
170+
const result2 = parseMultipart({
171+
responseText,
172+
lastProcessedLength: chunk1Text.length,
173+
boundary,
174+
});
175+
176+
expect(result2.chunks).toHaveLength(1);
177+
expect(result2.chunks[0]).toEqual(chunk2);
178+
expect(result2.lastProcessedLength).toBe(responseText.length);
179+
});
180+
181+
it('should ignore non-json content type', () => {
182+
const content = 'plain text content';
183+
const responseText = [
184+
`--${boundary}${CRLF}`,
185+
'Content-Type: text/plain\r\n',
186+
`Content-Length: ${content.length}\r\n`,
187+
'\r\n',
188+
content,
189+
].join('');
190+
191+
const result = parseMultipart({
192+
responseText,
193+
lastProcessedLength: 0,
194+
boundary,
195+
});
196+
197+
expect(result.chunks).toHaveLength(0);
198+
expect(result.lastProcessedLength).toBe(responseText.length);
199+
});
200+
});

src/services/parsers/parseMultipart.ts

Lines changed: 85 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,52 @@ interface MultipartResult {
77
}
88

99
const CRLF = '\r\n';
10+
const HEADER_VALUE_DELIMITER = ': ';
11+
12+
interface ParsedHeaders {
13+
contentType?: string;
14+
contentLength?: number;
15+
}
16+
17+
function parseHeaders(data: string, startPos: number): [ParsedHeaders, number] {
18+
const headers: ParsedHeaders = {};
19+
let pos = startPos;
20+
21+
while (pos < data.length) {
22+
// Check for end of headers
23+
if (data.startsWith(CRLF, pos)) {
24+
return [headers, pos + CRLF.length];
25+
}
26+
27+
const nextCRLF = data.indexOf(CRLF, pos);
28+
if (nextCRLF === -1) {
29+
// Headers are incomplete
30+
return [headers, startPos];
31+
}
32+
33+
const line = data.slice(pos, nextCRLF);
34+
const colonIndex = line.indexOf(HEADER_VALUE_DELIMITER);
35+
36+
if (colonIndex !== -1) {
37+
const header = line.slice(0, colonIndex).toLowerCase();
38+
const value = line.slice(colonIndex + HEADER_VALUE_DELIMITER.length, nextCRLF);
39+
40+
if (header.toLowerCase() === 'content-type') {
41+
headers.contentType = value;
42+
} else if (header.toLowerCase() === 'content-length') {
43+
const length = parseInt(value, 10);
44+
if (!isNaN(length)) {
45+
headers.contentLength = length;
46+
}
47+
}
48+
}
49+
50+
pos = nextCRLF + CRLF.length;
51+
}
52+
53+
// Headers are incomplete
54+
return [headers, startPos];
55+
}
1056

1157
export function parseMultipart({
1258
responseText,
@@ -17,47 +63,56 @@ export function parseMultipart({
1763
lastProcessedLength: number;
1864
boundary?: string;
1965
}): MultipartResult {
20-
const newData = responseText.slice(lastProcessedLength);
21-
22-
if (!newData) {
23-
return {chunks: [], lastProcessedLength};
24-
}
25-
26-
// Split on boundary with double dashes and CRLF
66+
const data = responseText;
2767
const boundaryStr = `--${boundary}${CRLF}`;
28-
const parts = newData.split(boundaryStr);
29-
30-
let currentPosition = lastProcessedLength;
68+
let pos = lastProcessedLength;
3169
const chunks: StreamingChunk[] = [];
3270

33-
for (let i = 0; i < parts.length; i++) {
34-
const part = parts[i];
35-
const isLastPart = i === parts.length - 1;
71+
while (pos < data.length) {
72+
// Look for boundary
73+
const boundaryPos = data.indexOf(boundaryStr, pos);
74+
if (boundaryPos === -1) {
75+
break;
76+
}
3677

37-
const lines = part.split(CRLF);
78+
// Move position past boundary
79+
pos = boundaryPos + boundaryStr.length;
3880

39-
const emptyLineIndex = lines.findIndex((line) => line === '');
40-
if (emptyLineIndex === -1 || !lines[emptyLineIndex + 1]) {
41-
if (isLastPart) {
42-
break;
43-
}
44-
continue;
81+
// Parse headers
82+
const [headers, contentStart] = parseHeaders(data, pos);
83+
if (contentStart === pos || !headers.contentLength) {
84+
// Headers were incomplete or invalid
85+
pos = lastProcessedLength;
86+
break;
4587
}
4688

47-
const jsonContent = lines[emptyLineIndex + 1];
89+
// Check if we have enough data for the content
90+
const contentEnd = contentStart + headers.contentLength;
91+
if (contentEnd > data.length) {
92+
// Content is incomplete
93+
pos = lastProcessedLength;
94+
break;
95+
}
4896

49-
let parsedChunk: StreamingChunk | null = null;
50-
try {
51-
parsedChunk = JSON.parse(jsonContent) as StreamingChunk;
52-
} catch {}
97+
// Extract content
98+
const content = data.slice(contentStart, contentEnd);
5399

54-
if (!parsedChunk) {
55-
break;
100+
// Try to parse JSON content
101+
try {
102+
if (headers.contentType === 'application/json') {
103+
const parsedChunk = JSON.parse(content) as StreamingChunk;
104+
chunks.push(parsedChunk);
105+
}
106+
} catch {
107+
// Invalid JSON, skip this chunk
56108
}
57109

58-
chunks.push(parsedChunk);
59-
currentPosition += boundaryStr.length + part.length;
110+
// Move position to end of content
111+
pos = contentEnd;
60112
}
61113

62-
return {chunks, lastProcessedLength: currentPosition};
114+
return {
115+
chunks,
116+
lastProcessedLength: pos,
117+
};
63118
}

0 commit comments

Comments
 (0)