From c9a3e8de28f6ae7180c5e451c4c58f9acc486b71 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 03:07:09 +0000 Subject: [PATCH 1/4] fix(node): keep multipart bytes intact when the request has an encoding set When something upstream calls `setEncoding` on the request, the stream yields strings. The form-data path passed the raw stream to `Response`, and undici encodes string chunks as utf8, so: - `latin1` silently corrupted file parts (`ff fe 00 80 41` arrived as `c3 bf c3 be 00 c2 80 41`). - `base64` and `hex` failed with `Failed to parse body as FormData`. The body now goes through `toWebReadableStream`, which encodes string chunks back with the stream's own encoding, like the json, file and stream paths since #131. A form-data body without a content-type now throws `Failed to parse body as FormData: missing content-type header` instead of sending the literal header `undefined` to the parser. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019hbEDi4A2Q9W3mUUGrMN3U --- packages/node/src/body.test.ts | 90 +++++++++++++++++++++++++++------- packages/node/src/body.ts | 13 ++++- 2 files changed, 84 insertions(+), 19 deletions(-) diff --git a/packages/node/src/body.test.ts b/packages/node/src/body.test.ts index a7a6ae1..ab5d912 100644 --- a/packages/node/src/body.test.ts +++ b/packages/node/src/body.test.ts @@ -284,28 +284,75 @@ describe('toStandardBody', () => { }) }) - describe.each(['utf8', 'base64'] as const)('request with %s encoding set', (encoding) => { - let standardBody: any + describe.each(['utf8', 'latin1', 'base64', 'hex'] as const)( + 'request with %s encoding set', + (encoding) => { + let standardBody: any - async function handler(req: IncomingMessage, res: ServerResponse): Promise { - req.setEncoding(encoding) - standardBody = await toStandardBody(req) - res.end() - } + async function handler(req: IncomingMessage, res: ServerResponse): Promise { + req.setEncoding(encoding) + standardBody = await toStandardBody(req) + res.end() + } - it('json', async () => { - await request(handler).post('/').set('standard-server', 'json').send('{"emoji":"😀"}') + it('json', async () => { + await request(handler).post('/').set('standard-server', 'json').send('{"emoji":"😀"}') - expect(standardBody).toEqual({ emoji: '😀' }) - }) + expect(standardBody).toEqual({ emoji: '😀' }) + }) - it('file', async () => { - await request(handler).post('/').set('standard-server', 'file').send('emoji=😀') + it('file', async () => { + await request(handler).post('/').set('standard-server', 'file').send('emoji=😀') - expect(standardBody).toBeInstanceOf(File) - expect(await standardBody.text()).toBe('emoji=😀') - }) - }) + expect(standardBody).toBeInstanceOf(File) + expect(await standardBody.text()).toBe('emoji=😀') + }) + + it('form-data', async () => { + await request(handler) + .post('/') + .field('emoji', '😀') + .attach('file', Buffer.from('emoji=😀'), 'foo.txt') + + expect(standardBody).toBeInstanceOf(FormData) + expect(standardBody.get('emoji')).toBe('😀') + expect(await standardBody.get('file').text()).toBe('emoji=😀') + }) + }, + ) + + // utf8 is left out: it decodes invalid byte sequences to U+FFFD, so binary data can't be recovered + describe.each(['latin1', 'base64', 'hex'] as const)( + 'binary request with %s encoding set', + (encoding) => { + const bytes = new Uint8Array([0xff, 0xfe, 0x00, 0x80, 0x41]) + let standardBody: any + + async function handler(req: IncomingMessage, res: ServerResponse): Promise { + req.setEncoding(encoding) + standardBody = await toStandardBody(req) + res.end() + } + + it('file', async () => { + await request(handler) + .post('/') + .set('standard-server', 'file') + .type('application/octet-stream') + .send(Buffer.from(bytes)) + + expect(standardBody).toBeInstanceOf(File) + expect(new Uint8Array(await standardBody.arrayBuffer())).toEqual(bytes) + }) + + it('form-data', async () => { + await request(handler).post('/').attach('file', Buffer.from(bytes), 'foo.bin') + + expect(standardBody).toBeInstanceOf(FormData) + expect(new Uint8Array(await standardBody.get('file').arrayBuffer())).toEqual(bytes) + }) + }, + ) describe('http2', () => { /** @@ -462,6 +509,15 @@ describe('toStandardBody', () => { expect(await reader.read()).toEqual({ done: false, value: 'hello' }) }) + it('throws a clear error on form-data without content-type', async () => { + const req = Readable.from([Buffer.from('--X--\r\n')]) as IncomingMessage + req.headers = { 'standard-server': 'form-data' } + + await expect(toStandardBody(req)).rejects.toThrow( + new TypeError('Failed to parse body as FormData: missing content-type header'), + ) + }) + it('falls back to the content headers if the body hint is invalid', async () => { let standardBody: any diff --git a/packages/node/src/body.ts b/packages/node/src/body.ts index 054ae79..b23fbef 100644 --- a/packages/node/src/body.ts +++ b/packages/node/src/body.ts @@ -161,8 +161,17 @@ export function toNodeHttpBody( return [stringifyJSON(body), headers] } -function _streamToFormData(stream: Readable, contentType: string | undefined): Promise { - const response = new Response(stream, { +async function _streamToFormData( + stream: Readable, + contentType: string | undefined, +): Promise { + // the boundary lives in the content-type, so the body can't be parsed without it + if (contentType === undefined) { + throw new TypeError('Failed to parse body as FormData: missing content-type header') + } + + // undici would encode string chunks (from `setEncoding`) as utf8, corrupting other encodings + const response = new Response(toWebReadableStream(stream), { headers: { 'content-type': contentType, }, From fd6c8c01e203ddcfbce0550fae729c506751b12b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 03:12:42 +0000 Subject: [PATCH 2/4] test(node): merge the binary encoding cases into the encoding block The binary block repeated the encoding block's handler and ran the same file and form-data paths for the same encodings. One block now picks the payload per encoding: arbitrary bytes for the lossless ones, valid text for utf8, which can't round-trip invalid byte sequences. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019hbEDi4A2Q9W3mUUGrMN3U --- packages/node/src/body.test.ts | 49 +++++++--------------------------- 1 file changed, 9 insertions(+), 40 deletions(-) diff --git a/packages/node/src/body.test.ts b/packages/node/src/body.test.ts index ab5d912..2c15772 100644 --- a/packages/node/src/body.test.ts +++ b/packages/node/src/body.test.ts @@ -287,6 +287,9 @@ describe('toStandardBody', () => { describe.each(['utf8', 'latin1', 'base64', 'hex'] as const)( 'request with %s encoding set', (encoding) => { + // utf8 decodes invalid byte sequences to U+FFFD, so only valid text survives it + const bytes = + encoding === 'utf8' ? Buffer.from('emoji=😀') : Buffer.from([0xff, 0xfe, 0x00, 0x80, 0x41]) let standardBody: any async function handler(req: IncomingMessage, res: ServerResponse): Promise { @@ -302,54 +305,20 @@ describe('toStandardBody', () => { }) it('file', async () => { - await request(handler).post('/').set('standard-server', 'file').send('emoji=😀') + await request(handler).post('/').set('standard-server', 'file').send(bytes) expect(standardBody).toBeInstanceOf(File) - expect(await standardBody.text()).toBe('emoji=😀') + expect(new Uint8Array(await standardBody.arrayBuffer())).toEqual(new Uint8Array(bytes)) }) it('form-data', async () => { - await request(handler) - .post('/') - .field('emoji', '😀') - .attach('file', Buffer.from('emoji=😀'), 'foo.txt') + await request(handler).post('/').field('emoji', '😀').attach('file', bytes, 'foo.bin') expect(standardBody).toBeInstanceOf(FormData) expect(standardBody.get('emoji')).toBe('😀') - expect(await standardBody.get('file').text()).toBe('emoji=😀') - }) - }, - ) - - // utf8 is left out: it decodes invalid byte sequences to U+FFFD, so binary data can't be recovered - describe.each(['latin1', 'base64', 'hex'] as const)( - 'binary request with %s encoding set', - (encoding) => { - const bytes = new Uint8Array([0xff, 0xfe, 0x00, 0x80, 0x41]) - let standardBody: any - - async function handler(req: IncomingMessage, res: ServerResponse): Promise { - req.setEncoding(encoding) - standardBody = await toStandardBody(req) - res.end() - } - - it('file', async () => { - await request(handler) - .post('/') - .set('standard-server', 'file') - .type('application/octet-stream') - .send(Buffer.from(bytes)) - - expect(standardBody).toBeInstanceOf(File) - expect(new Uint8Array(await standardBody.arrayBuffer())).toEqual(bytes) - }) - - it('form-data', async () => { - await request(handler).post('/').attach('file', Buffer.from(bytes), 'foo.bin') - - expect(standardBody).toBeInstanceOf(FormData) - expect(new Uint8Array(await standardBody.get('file').arrayBuffer())).toEqual(bytes) + expect(new Uint8Array(await standardBody.get('file').arrayBuffer())).toEqual( + new Uint8Array(bytes), + ) }) }, ) From 2eca02dbb89c102e3a05bb682620d379173e7d8c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 01:48:33 +0000 Subject: [PATCH 3/4] fix(node): let the form-data parser reject a missing content-type Drop the custom missing-content-type error and leave the rejection to `Response.formData()`, as the aws-lambda adapter does. An absent header is passed as `''` rather than the literal string `undefined`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019hbEDi4A2Q9W3mUUGrMN3U --- packages/node/src/body.test.ts | 6 ++---- packages/node/src/body.ts | 12 ++---------- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/packages/node/src/body.test.ts b/packages/node/src/body.test.ts index 2c15772..4cf99bc 100644 --- a/packages/node/src/body.test.ts +++ b/packages/node/src/body.test.ts @@ -478,13 +478,11 @@ describe('toStandardBody', () => { expect(await reader.read()).toEqual({ done: false, value: 'hello' }) }) - it('throws a clear error on form-data without content-type', async () => { + it('lets the parser reject form-data without content-type', async () => { const req = Readable.from([Buffer.from('--X--\r\n')]) as IncomingMessage req.headers = { 'standard-server': 'form-data' } - await expect(toStandardBody(req)).rejects.toThrow( - new TypeError('Failed to parse body as FormData: missing content-type header'), - ) + await expect(toStandardBody(req)).rejects.toThrow(TypeError) }) it('falls back to the content headers if the body hint is invalid', async () => { diff --git a/packages/node/src/body.ts b/packages/node/src/body.ts index b23fbef..e71aa00 100644 --- a/packages/node/src/body.ts +++ b/packages/node/src/body.ts @@ -161,19 +161,11 @@ export function toNodeHttpBody( return [stringifyJSON(body), headers] } -async function _streamToFormData( - stream: Readable, - contentType: string | undefined, -): Promise { - // the boundary lives in the content-type, so the body can't be parsed without it - if (contentType === undefined) { - throw new TypeError('Failed to parse body as FormData: missing content-type header') - } - +function _streamToFormData(stream: Readable, contentType: string | undefined): Promise { // undici would encode string chunks (from `setEncoding`) as utf8, corrupting other encodings const response = new Response(toWebReadableStream(stream), { headers: { - 'content-type': contentType, + 'content-type': contentType ?? '', }, }) From 1bdc3d0bc614b63b8e9e0c611b14d10a55499bf0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 01:57:44 +0000 Subject: [PATCH 4/4] refactor(node): drop the form-data encoding comment the tests now cover Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019hbEDi4A2Q9W3mUUGrMN3U --- packages/node/src/body.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/node/src/body.ts b/packages/node/src/body.ts index e71aa00..ca3a24f 100644 --- a/packages/node/src/body.ts +++ b/packages/node/src/body.ts @@ -162,7 +162,6 @@ export function toNodeHttpBody( } function _streamToFormData(stream: Readable, contentType: string | undefined): Promise { - // undici would encode string chunks (from `setEncoding`) as utf8, corrupting other encodings const response = new Response(toWebReadableStream(stream), { headers: { 'content-type': contentType ?? '',