From 1b72c33f3311f2f559bffcca5b72c34c2aae86da Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 08:08:23 +0000 Subject: [PATCH] fix(peer): keep FormData boundary case in encoded content-type Blob.type is ASCII-lowercased, so using it as the content-type broke mixed-case multipart boundaries (e.g. Chrome's ----WebKitFormBoundary...) and the receiving side failed to parse the form data. Use the Response content-type header instead, read before consuming the body since Bun drops it afterwards when the FormData contains a File (same as the node adapter). Fixes #182 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7Qw8xM3RXrXjKh1gBc --- packages/node/src/body.ts | 1 + packages/peer/src/body.test.ts | 42 ++++++++++++++++++++++++++++++++++ packages/peer/src/body.ts | 3 ++- 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/node/src/body.ts b/packages/node/src/body.ts index 4c95610..c1d2ba0 100644 --- a/packages/node/src/body.ts +++ b/packages/node/src/body.ts @@ -153,6 +153,7 @@ export function toNodeHttpBody( if (body instanceof FormData) { const response = new Response(body) + // Read before the body: Bun drops the header once a body containing a File is consumed headers['content-type'] = response.headers.get('content-type')! return [Readable.fromWeb(response.body!), headers] } diff --git a/packages/peer/src/body.test.ts b/packages/peer/src/body.test.ts index 3449e1d..e8c73e8 100644 --- a/packages/peer/src/body.test.ts +++ b/packages/peer/src/body.test.ts @@ -382,6 +382,48 @@ describe('encodeAtomicStandardBody', () => { expect(binary).toBeInstanceOf(Blob) }) + it('keeps the case of a mixed-case FormData boundary', async () => { + const boundary = '----WebKitFormBoundaryQLr0YLsTkVq7MA89' + const OriginalResponse = Response + vi.stubGlobal( + 'Response', + class extends OriginalResponse { + constructor(...args: ConstructorParameters) { + if (args[0] instanceof FormData) { + super( + `--${boundary}\r\nContent-Disposition: form-data; name="key"\r\n\r\nvalue\r\n--${boundary}--\r\n`, + { headers: { 'content-type': `multipart/form-data; boundary=${boundary}` } }, + ) + } else { + super(...args) + } + } + }, + ) + onTestFinished(() => { + vi.unstubAllGlobals() + }) + + const form = new FormData() + form.append('key', 'value') + const encoded = await encodeAtomicStandardBody(form, {}) + + expect(encoded.headers['content-type']).toBe(`multipart/form-data; boundary=${boundary}`) + + const { resolveBody } = toStandardBody( + { + id: '1', + kind: 'request', + json: { url: '/upload', headers: encoded.headers, body: encoded.jsonBody }, + binary: encoded.binary, + }, + vi.fn(), + ) + + const received = (await resolveBody()) as FormData + expect(received.get('key')).toBe('value') + }) + it('encodes Blob body', async () => { const blob = new Blob(['data'], { type: 'text/plain' }) const { jsonBody, headers, binary } = await encodeAtomicStandardBody(blob, {}) diff --git a/packages/peer/src/body.ts b/packages/peer/src/body.ts index 311e3a9..3c47dd3 100644 --- a/packages/peer/src/body.ts +++ b/packages/peer/src/body.ts @@ -185,11 +185,12 @@ export async function encodeAtomicStandardBody( if (body instanceof FormData) { const res = new Response(body) + // Read before the body: Bun drops the header once a body containing a File is consumed + headers['content-type'] = res.headers.get('content-type')! const blob = await res.blob() // standard-server used to distinguish file vs. form-data headers['standard-server'] = 'form-data' satisfies StandardBodyHint - headers['content-type'] = blob.type headers['content-length'] = blob.size.toString() return { jsonBody: undefined, headers, binary: blob }