diff --git a/lib/form-data.js b/lib/form-data.js new file mode 100644 index 0000000..05ef796 --- /dev/null +++ b/lib/form-data.js @@ -0,0 +1,81 @@ +'use strict' + +const { randomUUID } = require('node:crypto') +const { Readable } = require('node:stream') + +let textEncoder + +function isFormDataLike (payload) { + return ( + payload && + typeof payload === 'object' && + typeof payload.append === 'function' && + typeof payload.delete === 'function' && + typeof payload.get === 'function' && + typeof payload.getAll === 'function' && + typeof payload.has === 'function' && + typeof payload.set === 'function' && + payload[Symbol.toStringTag] === 'FormData' + ) +} + +/* + partial code extraction and refactoring of `undici`. + MIT License. https://github.com/nodejs/undici/blob/043d8f1a89f606b1db259fc71f4c9bc8eb2aa1e6/lib/web/fetch/LICENSE + Reference https://github.com/nodejs/undici/blob/043d8f1a89f606b1db259fc71f4c9bc8eb2aa1e6/lib/web/fetch/body.js#L102-L168 +*/ +function formDataToStream (formdata) { + // lazy creation of TextEncoder + textEncoder = textEncoder ?? new TextEncoder() + + // we expect the function argument must be FormData + const boundary = `----formdata-${randomUUID()}` + const prefix = `--${boundary}\r\nContent-Disposition: form-data` + + /*! formdata-polyfill. MIT License. Jimmy Wärting */ + const escape = (str) => + str.replace(/\n/g, '%0A').replace(/\r/g, '%0D').replace(/"/g, '%22') + const normalizeLinefeeds = (value) => value.replace(/\r?\n|\r/g, '\r\n') + + const linebreak = new Uint8Array([13, 10]) // '\r\n' + + async function * asyncIterator () { + for (const [name, value] of formdata) { + if (typeof value === 'string') { + // header + yield textEncoder.encode(`${prefix}; name="${escape(normalizeLinefeeds(name))}"\r\n\r\n`) + // body + yield textEncoder.encode(`${normalizeLinefeeds(value)}\r\n`) + } else { + let header = `${prefix}; name="${escape(normalizeLinefeeds(name))}"` + value.name && (header += `; filename="${escape(value.name)}"`) + header += `\r\nContent-Type: ${value.type || 'application/octet-stream'}\r\n\r\n` + // header + yield textEncoder.encode(header) + // body + /* istanbul ignore else */ + if (value.stream) { + yield * value.stream() + } else { + // shouldn't be here since Blob / File should provide .stream + // and FormData always convert to USVString + /* istanbul ignore next */ + yield value + } + yield linebreak + } + } + // end + yield textEncoder.encode(`--${boundary}--`) + } + + const stream = Readable.from(asyncIterator()) + + return { + stream, + contentType: `multipart/form-data; boundary=${boundary}` + } +} + +module.exports.isFormDataLike = isFormDataLike +module.exports.formDataToStream = formDataToStream diff --git a/lib/request.js b/lib/request.js index 0e2a250..809ba0c 100644 --- a/lib/request.js +++ b/lib/request.js @@ -9,6 +9,7 @@ const assert = require('node:assert') const { createDeprecation } = require('process-warning') const parseURL = require('./parse-url') +const { isFormDataLike, formDataToStream } = require('./form-data') const { EventEmitter } = require('node:events') // request.connectin deprecation https://nodejs.org/api/http.html#http_request_connection @@ -146,7 +147,15 @@ function Request (options) { // we keep both payload and body for compatibility reasons let payload = options.payload || options.body || null - const payloadResume = payload && typeof payload.resume === 'function' + let payloadResume = payload && typeof payload.resume === 'function' + + if (isFormDataLike(payload)) { + const stream = formDataToStream(payload) + payload = stream.stream + payloadResume = true + // we override the content-type + this.headers['content-type'] = stream.contentType + } if (payload && typeof payload !== 'string' && !payloadResume && !Buffer.isBuffer(payload)) { payload = JSON.stringify(payload) diff --git a/package.json b/package.json index 472385a..aa8b3c6 100644 --- a/package.json +++ b/package.json @@ -14,14 +14,16 @@ "@fastify/ajv-compiler": "^3.1.0", "@fastify/pre-commit": "^2.0.2", "@types/node": "^20.1.0", - "tinybench": "^2.5.1", "end-of-stream": "^1.4.4", "express": "^4.17.1", "form-auto-content": "^3.0.0", "form-data": "^4.0.0", + "formdata-node": "^4.4.1", "standard": "^17.0.0", "tap": "^16.0.0", - "tsd": "^0.31.0" + "tinybench": "^2.5.1", + "tsd": "^0.31.0", + "undici": "^5.28.4" }, "scripts": { "benchmark": "node benchmark/benchmark.js", diff --git a/test/index.test.js b/test/index.test.js index 54889d3..55d5375 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -13,7 +13,7 @@ const express = require('express') const inject = require('../index') const parseURL = require('../lib/parse-url') -const FormData = require('form-data') +const NpmFormData = require('form-data') const formAutoContent = require('form-auto-content') const httpMethods = [ 'delete', @@ -1033,7 +1033,7 @@ test('form-data should be handled correctly', (t) => { }) } - const form = new FormData() + const form = new NpmFormData() form.append('my_field', 'my value') inject(dispatch, { @@ -2012,3 +2012,99 @@ test('request that is destroyed does not error', (t) => { t.equal(res.payload, 'hi') }) }) + +function runFormDataUnitTest (name, { FormData, Blob }) { + test(`${name} - form-data should be handled correctly`, (t) => { + t.plan(23) + + const dispatch = function (req, res) { + let body = '' + t.ok(/multipart\/form-data; boundary=----formdata-[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}(--)?$/.test(req.headers['content-type']), 'proper Content-Type provided') + req.on('data', d => { + body += d + }) + req.on('end', () => { + res.end(body) + }) + } + + const form = new FormData() + form.append('field', 'value') + form.append('blob', new Blob(['value']), '') + form.append('blob-with-type', new Blob(['value'], { type: 'text/plain' }), '') + form.append('blob-with-name', new Blob(['value']), 'file.txt') + form.append('number', 1) + + inject(dispatch, { + method: 'POST', + url: 'http://example.com:8080/hello', + payload: form + }, (err, res) => { + t.error(err) + t.equal(res.statusCode, 200) + + const regexp = [ + // header + /^------formdata-[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}(--)?$/, + // content-disposition + /^Content-Disposition: form-data; name="(.*)"(; filename="(.*)")?$/, + // content-type + /^Content-Type: (.*)$/ + ] + const readable = Readable.from(res.body.split('\r\n')) + let i = 1 + readable.on('data', function (chunk) { + switch (i) { + case 1: + case 5: + case 10: + case 15: + case 20: { + // header + t.ok(regexp[0].test(chunk), 'correct header') + break + } + case 2: + case 6: + case 11: + case 16: { + // content-disposition + t.ok(regexp[1].test(chunk), 'correct content-disposition') + break + } + case 7: + case 12: + case 17: { + // content-type + t.ok(regexp[2].test(chunk), 'correct content-type') + break + } + case 3: + case 8: + case 13: + case 18: { + // empty + t.equal(chunk, '', 'correct space') + break + } + case 4: + case 9: + case 14: + case 19: { + // value + t.equal(chunk, 'value', 'correct value') + break + } + } + i++ + }) + }) + }, { skip: FormData == null || Blob == null }) +} + +// supports >= node@18 +runFormDataUnitTest('native', { FormData: globalThis.FormData, Blob: globalThis.Blob }) +// supports >= node@16 +runFormDataUnitTest('undici', { FormData: require('undici').FormData, Blob: require('node:buffer').Blob }) +// supports >= node@14 +runFormDataUnitTest('formdata-node', { FormData: require('formdata-node').FormData, Blob: require('formdata-node').Blob })