Skip to content

Commit 38d91d0

Browse files
cameron-robeymetcoder95
authored andcommitted
feat: add support for multipart/form-data (nodejs#1606)
* add support for multipart/form-data * Handle busboy errors * linting * Catch emitted error * reject promise instead of throwing error * Add test for base64 encoded multipart/form-data Thanks for the help @mrbbot ! * Move busboy from devDependencies to dependencies * Add test for busboy emitting error * Rewrite tests * Update tests to avoid promises and callbacks
1 parent dd8e78a commit 38d91d0

File tree

3 files changed

+107
-7
lines changed

3 files changed

+107
-7
lines changed

lib/fetch/body.js

+43-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use strict'
22

3+
const Busboy = require('busboy')
34
const util = require('../core/util')
45
const { ReadableStreamFrom, toUSVString, isBlobLike } = require('./util')
56
const { FormData } = require('./formdata')
@@ -9,9 +10,9 @@ const { DOMException } = require('./constants')
910
const { Blob } = require('buffer')
1011
const { kBodyUsed } = require('../core/symbols')
1112
const assert = require('assert')
12-
const { NotSupportedError } = require('../core/errors')
1313
const { isErrored } = require('../core/util')
1414
const { isUint8Array, isArrayBuffer } = require('util/types')
15+
const { File } = require('./file')
1516

1617
let ReadableStream
1718

@@ -414,7 +415,47 @@ function bodyMixinMethods (instance) {
414415

415416
// If mimeType’s essence is "multipart/form-data", then:
416417
if (/multipart\/form-data/.test(contentType)) {
417-
throw new NotSupportedError('multipart/form-data not supported')
418+
const headers = {}
419+
for (const [key, value] of this.headers) headers[key.toLowerCase()] = value
420+
421+
const responseFormData = new FormData()
422+
423+
let busboy
424+
425+
try {
426+
busboy = Busboy({ headers })
427+
} catch (err) {
428+
// Error due to headers:
429+
throw Object.assign(new TypeError(), { cause: err })
430+
}
431+
432+
busboy.on('field', (name, value) => {
433+
responseFormData.append(name, value)
434+
})
435+
busboy.on('file', (name, value, info) => {
436+
const { filename, encoding, mimeType } = info
437+
const base64 = encoding.toLowerCase() === 'base64'
438+
const chunks = []
439+
value.on('data', (chunk) => {
440+
if (base64) chunk = Buffer.from(chunk.toString(), 'base64')
441+
chunks.push(chunk)
442+
})
443+
value.on('end', () => {
444+
const file = new File(chunks, filename, { type: mimeType })
445+
responseFormData.append(name, file)
446+
})
447+
})
448+
449+
const busboyResolve = new Promise((resolve, reject) => {
450+
busboy.on('finish', resolve)
451+
busboy.on('error', (err) => reject(err))
452+
})
453+
454+
if (this.body !== null) for await (const chunk of consumeBody(this[kState].body)) busboy.write(chunk)
455+
busboy.end()
456+
await busboyResolve
457+
458+
return responseFormData
418459
} else if (/application\/x-www-form-urlencoded/.test(contentType)) {
419460
// Otherwise, if mimeType’s essence is "application/x-www-form-urlencoded", then:
420461

package.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,6 @@
6969
"@types/node": "^17.0.45",
7070
"abort-controller": "^3.0.0",
7171
"atomic-sleep": "^1.0.0",
72-
"busboy": "^1.6.0",
7372
"chai": "^4.3.4",
7473
"chai-as-promised": "^7.1.1",
7574
"chai-iterator": "^3.0.2",
@@ -125,5 +124,8 @@
125124
"testMatch": [
126125
"<rootDir>/test/jest/**"
127126
]
127+
},
128+
"dependencies": {
129+
"busboy": "^1.6.0"
128130
}
129131
}

test/fetch/client-fetch.js

+61-4
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const { Client, setGlobalDispatcher, Agent } = require('../..')
1111
const nodeFetch = require('../../index-fetch')
1212
const { once } = require('events')
1313
const { gzipSync } = require('zlib')
14+
const { promisify } = require('util')
1415

1516
setGlobalDispatcher(new Agent({
1617
keepAliveTimeout: 1,
@@ -165,24 +166,80 @@ test('unsupported formData 1', (t) => {
165166
})
166167
})
167168

168-
test('unsupported formData 2', (t) => {
169+
test('multipart formdata not base64', async (t) => {
170+
t.plan(2)
171+
// Construct example form data, with text and blob fields
172+
const formData = new FormData()
173+
formData.append('field1', 'value1')
174+
const blob = new Blob(['example\ntext file'], { type: 'text/plain' })
175+
formData.append('field2', blob, 'file.txt')
176+
177+
const tempRes = new Response(formData)
178+
const boundary = tempRes.headers.get('content-type').split('boundary=')[1]
179+
const formRaw = await tempRes.text()
180+
181+
const server = createServer((req, res) => {
182+
res.setHeader('content-type', 'multipart/form-data; boundary=' + boundary)
183+
res.write(formRaw)
184+
res.end()
185+
})
186+
t.teardown(server.close.bind(server))
187+
188+
const listen = promisify(server.listen.bind(server))
189+
await listen(0)
190+
191+
const res = await fetch(`http://localhost:${server.address().port}`)
192+
const form = await res.formData()
193+
t.equal(form.get('field1'), 'value1')
194+
195+
const text = await form.get('field2').text()
196+
t.equal(text, 'example\ntext file')
197+
})
198+
199+
test('multipart formdata base64', (t) => {
169200
t.plan(1)
170201

202+
// Example form data with base64 encoding
203+
const formRaw = '------formdata-undici-0.5786922755719377\r\nContent-Disposition: form-data; name="key"; filename="test.txt"\r\nContent-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\n\r\ndmFsdWU=\r\n------formdata-undici-0.5786922755719377--'
171204
const server = createServer((req, res) => {
172-
res.setHeader('content-type', 'multipart/form-data')
205+
res.setHeader('content-type', 'multipart/form-data; boundary=----formdata-undici-0.5786922755719377')
206+
res.write(formRaw)
173207
res.end()
174208
})
175209
t.teardown(server.close.bind(server))
176210

177211
server.listen(0, () => {
178212
fetch(`http://localhost:${server.address().port}`)
179213
.then(res => res.formData())
180-
.catch(err => {
181-
t.equal(err.name, 'NotSupportedError')
214+
.then(form => form.get('key').text())
215+
.then(text => {
216+
t.equal(text, 'value')
182217
})
183218
})
184219
})
185220

221+
test('busboy emit error', async (t) => {
222+
t.plan(1)
223+
const formData = new FormData()
224+
formData.append('field1', 'value1')
225+
226+
const tempRes = new Response(formData)
227+
const formRaw = await tempRes.text()
228+
229+
const server = createServer((req, res) => {
230+
res.setHeader('content-type', 'multipart/form-data; boundary=wrongboundary')
231+
res.write(formRaw)
232+
res.end()
233+
})
234+
t.teardown(server.close.bind(server))
235+
236+
const listen = promisify(server.listen.bind(server))
237+
await listen(0)
238+
239+
const res = await fetch(`http://localhost:${server.address().port}`)
240+
await t.rejects(res.formData(), 'Unexpected end of multipart data')
241+
})
242+
186243
test('urlencoded formData', (t) => {
187244
t.plan(2)
188245

0 commit comments

Comments
 (0)