Skip to content

Commit

Permalink
feat: add support for multipart/form-data (#1606)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
cameron-robey authored Sep 28, 2022
1 parent 3ab0124 commit 2d38b7e
Show file tree
Hide file tree
Showing 3 changed files with 107 additions and 7 deletions.
45 changes: 43 additions & 2 deletions lib/fetch/body.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use strict'

const Busboy = require('busboy')
const util = require('../core/util')
const { ReadableStreamFrom, toUSVString, isBlobLike } = require('./util')
const { FormData } = require('./formdata')
Expand All @@ -9,9 +10,9 @@ const { DOMException } = require('./constants')
const { Blob } = require('buffer')
const { kBodyUsed } = require('../core/symbols')
const assert = require('assert')
const { NotSupportedError } = require('../core/errors')
const { isErrored } = require('../core/util')
const { isUint8Array, isArrayBuffer } = require('util/types')
const { File } = require('./file')

let ReadableStream

Expand Down Expand Up @@ -414,7 +415,47 @@ function bodyMixinMethods (instance) {

// If mimeType’s essence is "multipart/form-data", then:
if (/multipart\/form-data/.test(contentType)) {
throw new NotSupportedError('multipart/form-data not supported')
const headers = {}
for (const [key, value] of this.headers) headers[key.toLowerCase()] = value

const responseFormData = new FormData()

let busboy

try {
busboy = Busboy({ headers })
} catch (err) {
// Error due to headers:
throw Object.assign(new TypeError(), { cause: err })
}

busboy.on('field', (name, value) => {
responseFormData.append(name, value)
})
busboy.on('file', (name, value, info) => {
const { filename, encoding, mimeType } = info
const base64 = encoding.toLowerCase() === 'base64'
const chunks = []
value.on('data', (chunk) => {
if (base64) chunk = Buffer.from(chunk.toString(), 'base64')
chunks.push(chunk)
})
value.on('end', () => {
const file = new File(chunks, filename, { type: mimeType })
responseFormData.append(name, file)
})
})

const busboyResolve = new Promise((resolve, reject) => {
busboy.on('finish', resolve)
busboy.on('error', (err) => reject(err))
})

if (this.body !== null) for await (const chunk of consumeBody(this[kState].body)) busboy.write(chunk)
busboy.end()
await busboyResolve

return responseFormData
} else if (/application\/x-www-form-urlencoded/.test(contentType)) {
// Otherwise, if mimeType’s essence is "application/x-www-form-urlencoded", then:

Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,6 @@
"@types/node": "^17.0.45",
"abort-controller": "^3.0.0",
"atomic-sleep": "^1.0.0",
"busboy": "^1.6.0",
"chai": "^4.3.4",
"chai-as-promised": "^7.1.1",
"chai-iterator": "^3.0.2",
Expand Down Expand Up @@ -125,5 +124,8 @@
"testMatch": [
"<rootDir>/test/jest/**"
]
},
"dependencies": {
"busboy": "^1.6.0"
}
}
65 changes: 61 additions & 4 deletions test/fetch/client-fetch.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const { Client, setGlobalDispatcher, Agent } = require('../..')
const nodeFetch = require('../../index-fetch')
const { once } = require('events')
const { gzipSync } = require('zlib')
const { promisify } = require('util')

setGlobalDispatcher(new Agent({
keepAliveTimeout: 1,
Expand Down Expand Up @@ -165,24 +166,80 @@ test('unsupported formData 1', (t) => {
})
})

test('unsupported formData 2', (t) => {
test('multipart formdata not base64', async (t) => {
t.plan(2)
// Construct example form data, with text and blob fields
const formData = new FormData()
formData.append('field1', 'value1')
const blob = new Blob(['example\ntext file'], { type: 'text/plain' })
formData.append('field2', blob, 'file.txt')

const tempRes = new Response(formData)
const boundary = tempRes.headers.get('content-type').split('boundary=')[1]
const formRaw = await tempRes.text()

const server = createServer((req, res) => {
res.setHeader('content-type', 'multipart/form-data; boundary=' + boundary)
res.write(formRaw)
res.end()
})
t.teardown(server.close.bind(server))

const listen = promisify(server.listen.bind(server))
await listen(0)

const res = await fetch(`http://localhost:${server.address().port}`)
const form = await res.formData()
t.equal(form.get('field1'), 'value1')

const text = await form.get('field2').text()
t.equal(text, 'example\ntext file')
})

test('multipart formdata base64', (t) => {
t.plan(1)

// Example form data with base64 encoding
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--'
const server = createServer((req, res) => {
res.setHeader('content-type', 'multipart/form-data')
res.setHeader('content-type', 'multipart/form-data; boundary=----formdata-undici-0.5786922755719377')
res.write(formRaw)
res.end()
})
t.teardown(server.close.bind(server))

server.listen(0, () => {
fetch(`http://localhost:${server.address().port}`)
.then(res => res.formData())
.catch(err => {
t.equal(err.name, 'NotSupportedError')
.then(form => form.get('key').text())
.then(text => {
t.equal(text, 'value')
})
})
})

test('busboy emit error', async (t) => {
t.plan(1)
const formData = new FormData()
formData.append('field1', 'value1')

const tempRes = new Response(formData)
const formRaw = await tempRes.text()

const server = createServer((req, res) => {
res.setHeader('content-type', 'multipart/form-data; boundary=wrongboundary')
res.write(formRaw)
res.end()
})
t.teardown(server.close.bind(server))

const listen = promisify(server.listen.bind(server))
await listen(0)

const res = await fetch(`http://localhost:${server.address().port}`)
await t.rejects(res.formData(), 'Unexpected end of multipart data')
})

test('urlencoded formData', (t) => {
t.plan(2)

Expand Down

0 comments on commit 2d38b7e

Please sign in to comment.