Skip to content

Commit

Permalink
feat: support FormData (#286)
Browse files Browse the repository at this point in the history
  • Loading branch information
climba03003 authored Apr 10, 2024
1 parent 15a2a26 commit 974a1c9
Show file tree
Hide file tree
Showing 4 changed files with 193 additions and 5 deletions.
81 changes: 81 additions & 0 deletions lib/form-data.js
Original file line number Diff line number Diff line change
@@ -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 <https://jimmy.warting.se/opensource> */
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
11 changes: 10 additions & 1 deletion lib/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
100 changes: 98 additions & 2 deletions test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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 })

0 comments on commit 974a1c9

Please sign in to comment.