Skip to content

Commit 255f3bc

Browse files
committed
feat: proper chunk decoding
1 parent ef32cc9 commit 255f3bc

File tree

11 files changed

+251
-19
lines changed

11 files changed

+251
-19
lines changed

src/http/error/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
1-
export { InvalidStartLineError } from './invalid_start_line_error.ts'
1+
export { InvalidChunkSizeError } from './invalid_chunk_size_error.ts'
22
export { InvalidResponseError } from './invalid_response_error.ts'
3+
export { InvalidStartLineError } from './invalid_start_line_error.ts'
4+
export { MalformedChunkedEncodingError } from './malformed_chunked_encoding_error.ts'
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export class InvalidChunkSizeError extends Error {
2+
constructor() {
3+
super(`Invalid chunk size`);
4+
}
5+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export class MalformedChunkedEncodingError extends Error {
2+
constructor(message : string) {
3+
super(`Malformed chunked encoding: ${message}`);
4+
}
5+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { assertEquals, assertThrows } from '@std/assert'
2+
import { parseChunkedBody } from './parse_chunked_body.ts';
3+
import { InvalidChunkSizeError, MalformedChunkedEncodingError } from '../../error/index.ts';
4+
5+
Deno.test('Tests "parseChunkedBody"', async (test) => {
6+
await test.step({
7+
name: 'Parse valid chunked body.',
8+
fn() : void {
9+
const responseContent = '6\r\nHello \r\n6\r\nWorld!\r\n0\r\n\r\n'
10+
assertEquals(
11+
parseChunkedBody(responseContent),
12+
'Hello World!'
13+
)
14+
}
15+
})
16+
17+
await test.step({
18+
name: 'Parse invalid chunked body. Wrong chunk size.',
19+
fn() : void {
20+
const responseContent = '6\r\nHello \r\n6\r\nWor\r\n0\r\n\r\n'
21+
assertThrows(
22+
() => parseChunkedBody(responseContent),
23+
InvalidChunkSizeError,
24+
'Invalid chunk size'
25+
)
26+
}
27+
})
28+
29+
await test.step({
30+
name: 'Parse invalid chunked body. Missing chunk size delimiter.',
31+
fn() : void {
32+
const responseContent = '6'
33+
assertThrows(
34+
() => parseChunkedBody(responseContent),
35+
MalformedChunkedEncodingError,
36+
'Malformed chunked encoding: missing chunk size delimiter'
37+
)
38+
}
39+
})
40+
41+
await test.step({
42+
name: 'Parse invalid chunked body. Chunk size to small for chunk.',
43+
fn() : void {
44+
const responseContent = '6\r\nHello \r\n2\r\nWorld!\r\n0\r\n\r\n'
45+
assertThrows(
46+
() => parseChunkedBody(responseContent),
47+
MalformedChunkedEncodingError,
48+
'Malformed chunked encoding: missing chunk data delimiter'
49+
)
50+
}
51+
})
52+
53+
await test.step({
54+
name: 'Parse invalid chunked body. Chunk size to big for chunk (exceeds end).',
55+
fn() : void {
56+
const responseContent = '6\r\nHello \r\n20\r\nWorld!\r\n0\r\n\r\n'
57+
assertThrows(
58+
() => parseChunkedBody(responseContent),
59+
MalformedChunkedEncodingError,
60+
'Malformed chunked encoding: incomplete chunk data'
61+
)
62+
}
63+
})
64+
65+
await test.step({
66+
name: 'Parse invalid chunked body. Missing 0 at end.',
67+
fn() : void {
68+
const responseContent = '6\r\nHello \r\n2\r\nWor\r\n'
69+
assertThrows(
70+
() => parseChunkedBody(responseContent),
71+
MalformedChunkedEncodingError,
72+
'Malformed chunked encoding: missing chunk data delimiter'
73+
)
74+
}
75+
})
76+
77+
await test.step({
78+
name: 'Parse invalid chunked body. Missing \\r\\n\\r\\n at end.',
79+
fn() : void {
80+
const responseContent = '6\r\nHello \r\n6\r\nWorld!\r\n0'
81+
assertThrows(
82+
() => parseChunkedBody(responseContent),
83+
MalformedChunkedEncodingError,
84+
'Malformed chunked encoding: missing chunk size delimiter'
85+
)
86+
}
87+
})
88+
})
Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,73 @@
1+
import { InvalidChunkSizeError, MalformedChunkedEncodingError } from '../../error/index.ts';
2+
3+
/**
4+
* Parses an HTTP chunked transfer encoded body and returns the decoded content.
5+
*
6+
* Chunked encoding format:
7+
* - Each chunk starts with its size in hexadecimal followed by \r\n
8+
* - Then the chunk data followed by \r\n
9+
* - Final chunk has size 0
10+
*
11+
* @example
12+
* ```
13+
* 5\r\n
14+
* Hello\r\n
15+
* 6\r\n
16+
* World\r\n
17+
* 0\r\n
18+
* \r\n
19+
* ```
20+
* Results in: "Wikipedia"
21+
*
22+
* @param body The chunked encoded body string
23+
* @throws `MalformedChunkedEncodingError` if the chunked encoding is malformed
24+
* @returns The decoded body content
25+
*/
126
export function parseChunkedBody(body : string) : string {
2-
return body
27+
let result = '';
28+
let position = 0;
29+
30+
while (position < body.length) {
31+
const chunkSizeEnd = body.indexOf("\r\n", position);
32+
33+
if (chunkSizeEnd === -1) {
34+
throw new MalformedChunkedEncodingError('missing chunk size delimiter');
35+
}
36+
37+
const chunkSizeHex = body.slice(position, chunkSizeEnd).trim();
38+
39+
// Handle chunk extensions (ex. "1a; name=value")
40+
const chunkSizeOnly = chunkSizeHex.split(';')[0].trim();
41+
const chunkSize = parseInt(chunkSizeOnly, 16);
42+
43+
if (isNaN(chunkSize)) {
44+
throw new InvalidChunkSizeError();
45+
}
46+
47+
// Chunk size 0 indicates the end
48+
if (chunkSize === 0) {
49+
break;
50+
}
51+
52+
position = chunkSizeEnd + 2; // +2 for \r\n
53+
54+
// Verify we have enough data for the chunk
55+
if (position + chunkSize > body.length) {
56+
throw new MalformedChunkedEncodingError('incomplete chunk data');
57+
}
58+
59+
// Extract chunk data
60+
const chunkData = body.slice(position, position + chunkSize);
61+
result += chunkData;
62+
63+
position += chunkSize;
64+
65+
if (body.slice(position, position + 2) !== "\r\n") {
66+
throw new MalformedChunkedEncodingError('missing chunk data delimiter');
67+
}
68+
69+
position += 2; // Skip trailing \r\n
70+
}
71+
72+
return result;
373
}

src/http/response/headers/parse_headers.test.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,26 @@ Deno.test('Tests "parseHeaders"', async (test) => {
55
await test.step({
66
name: 'Parse valid HTTP headers.',
77
fn() : void {
8-
const responseContent = 'key-1: value1\r\nkey-2: value 2 with space'
8+
const responseContent = 'key-1: Value1\r\nkey-2: Value 2 with Space'
99
assertEquals(
1010
Object.fromEntries(parseHeaders(responseContent)),
1111
{
12-
'key-1': 'value1',
13-
'key-2': 'value 2 with space'
12+
'key-1': 'Value1',
13+
'key-2': 'Value 2 with Space'
14+
}
15+
)
16+
}
17+
})
18+
19+
await test.step({
20+
name: 'Parse (valid) HTTP headers. Keys should be lower-case. Values not.',
21+
fn() : void {
22+
const responseContent = '\r\nKey-1: Value1\r\nKey-2: Value 2 with Space\r\n'
23+
assertEquals(
24+
Object.fromEntries(parseHeaders(responseContent)),
25+
{
26+
'key-1': 'Value1',
27+
'key-2': 'Value 2 with Space'
1428
}
1529
)
1630
}
@@ -19,7 +33,7 @@ Deno.test('Tests "parseHeaders"', async (test) => {
1933
await test.step({
2034
name: 'Parse (valid) HTTP headers. With leading/trailing newline.',
2135
fn() : void {
22-
const responseContent = '\r\nkey-1: value1\r\nkey-2: value 2 with space\r\n'
36+
const responseContent = '\r\nKey-1: value1\r\nKey-2: value 2 with space\r\n'
2337
assertEquals(
2438
Object.fromEntries(parseHeaders(responseContent)),
2539
{

src/http/response/headers/parse_headers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export function parseHeaders(response : string) : Map<string, string> {
88
const headers = new Map<string, string>();
99
response.trim().split('\r\n').forEach((line) => {
1010
const [key, value] = line.split(':');
11-
headers.set(key, value.trim());
11+
headers.set(key.toLocaleLowerCase(), value.trim());
1212
})
1313

1414
return headers;

src/http/response/parse.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { assertEquals } from '@std/assert'
2+
import { parse } from './parse.ts';
3+
4+
Deno.test('Tests "parse"', async (test) => {
5+
await test.step({
6+
name: 'Parse valid HTTP response.',
7+
async fn() : Promise<void> {
8+
const exampleResponse = 'HTTP/1.1 200 OK\r\nFoo-Version: 1.01\r\nContent-Type: application/json\r\nBar-Experimental: false\r\nServer: Bazz/0.0.0 (bazz)\r\nDate: Mon, 10 Nov 2025 15:42:24 GMT\r\nConnection: close\r\n\r\nHello World!\r\n\r\n';
9+
10+
const response = parse(exampleResponse);
11+
12+
assertEquals(
13+
await response.text(),
14+
'Hello World!'
15+
)
16+
}
17+
})
18+
19+
await test.step({
20+
name: 'Parse valid HTTP response. Transfer-Encoded: chunked',
21+
async fn() : Promise<void> {
22+
const exampleResponse = 'HTTP/1.1 200 OK\r\nFoo-Version: 1.01\r\nContent-Type: application/text\r\nBar-Experimental: false\r\nServer: Bazz/0.0.0 (bazz)\r\nDate: Mon, 10 Nov 2025 15:42:24 GMT\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n\r\n6\r\nHello \r\n6\r\nWorld!\r\n0\r\n';
23+
24+
const response = parse(exampleResponse);
25+
26+
assertEquals(
27+
await response.text(),
28+
'Hello World!'
29+
)
30+
}
31+
})
32+
33+
await test.step({
34+
name: 'Parse valid HTTP response. Transfer-Encoded: chunked',
35+
async fn() : Promise<void> {
36+
const exampleResponse = 'HTTP/1.1 200 OK\r\nFoo-Version: 1.01\r\nContent-Type: application/json\r\nBar-Experimental: false\r\nServer: Bazz/0.0.0 (bazz)\r\nDate: Mon, 10 Nov 2025 15:42:24 GMT\r\nConnection: close\r\n\r\n{"foo": "bar"}\r\n';
37+
38+
const response = parse(exampleResponse);
39+
40+
assertEquals(
41+
await response.json(),
42+
{foo: 'bar'}
43+
)
44+
}
45+
})
46+
})

src/http/response/parse.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ export function parse(response : string) : Response {
1010
const headers = parseHeaders(splitResult.headers);
1111
let body = splitResult.body;
1212

13-
if (headers.get('Transfer-Encoding') === 'chunked') {
13+
if (headers.get('transfer-encoding') === 'chunked') {
1414
body = parseChunkedBody(body);
15+
} else {
16+
body = body.replace(/\r\n\r\n$/gm, '');
1517
}
1618

1719
return new Response(

src/http/response/split_response.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@ Deno.test('Tests "splitResponse"', async (test) => {
66
await test.step({
77
name: 'Split valid HTTP response.',
88
fn() : void {
9-
const responseContent = 'HTTP/1.1 200 OK\r\nfoo: bar\r\n\r\nHello World'
9+
const responseContent = 'HTTP/1.1 200 OK\r\nkey-1: value 1\r\nkey-2: value 2\r\n\r\nHello World'
1010
assertEquals(
1111
splitResponse(responseContent),
1212
{
1313
startLine: 'HTTP/1.1 200 OK',
14-
headers: 'foo: bar',
14+
headers: 'key-1: value 1\r\nkey-2: value 2',
1515
body: 'Hello World'
1616
}
1717
)
@@ -21,12 +21,12 @@ Deno.test('Tests "splitResponse"', async (test) => {
2121
await test.step({
2222
name: 'Split valid HTTP response. Empty body.',
2323
fn() : void {
24-
const responseContent = 'HTTP/1.1 200 OK\r\nfoo: bar\r\n\r\n'
24+
const responseContent = 'HTTP/1.1 200 OK\r\nkey-1: value 1\r\nkey-2: value 2\r\n\r\n'
2525
assertEquals(
2626
splitResponse(responseContent),
2727
{
2828
startLine: 'HTTP/1.1 200 OK',
29-
headers: 'foo: bar',
29+
headers: 'key-1: value 1\r\nkey-2: value 2',
3030
body: ''
3131
}
3232
)
@@ -36,7 +36,7 @@ Deno.test('Tests "splitResponse"', async (test) => {
3636
await test.step({
3737
name: 'Split invalid HTTP response.',
3838
fn() : void {
39-
const responseContent = 'HTTP/1.1 OK\r\nfoo: bar\r\n'
39+
const responseContent = 'HTTP/1.1 OK\r\nkey-1: value 1\r\nkey-2: value 2\r\n'
4040
assertThrows(
4141
() => splitResponse(responseContent),
4242
InvalidResponseError

0 commit comments

Comments
 (0)