diff --git a/lib/fetch/body.js b/lib/fetch/body.js index 08d22310a38..6d5f7b0bfe4 100644 --- a/lib/fetch/body.js +++ b/lib/fetch/body.js @@ -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') @@ -8,9 +9,9 @@ const { webidl } = require('./webidl') 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 @@ -397,7 +398,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: diff --git a/package.json b/package.json index 0b9d4f713e4..3ce23d0bc31 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,6 @@ "@types/node": "^17.0.29", "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", @@ -122,5 +121,8 @@ "testMatch": [ "/test/jest/**" ] + }, + "dependencies": { + "busboy": "^1.6.0" } } diff --git a/test/fetch/client-fetch.js b/test/fetch/client-fetch.js index 4ec4545d544..3d588248570 100644 --- a/test/fetch/client-fetch.js +++ b/test/fetch/client-fetch.js @@ -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, @@ -165,11 +166,44 @@ 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)) @@ -177,12 +211,35 @@ test('unsupported formData 2', (t) => { 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)