Skip to content

Commit

Permalink
implement sync formdata parser (nodejs#2911)
Browse files Browse the repository at this point in the history
* implement sync formdata parsser

* move utf8decodebytes

* fixup

* fixup! off-by-one

* fixup

* fixup

* fix bugs, fix lint

* mark wpts as passing

* this one fails on node 18

* apply suggestions

* fixup! body can end with CRLF
  • Loading branch information
KhafraDev authored and tsctx committed Mar 8, 2024
1 parent 8610021 commit fd7ab4f
Show file tree
Hide file tree
Showing 9 changed files with 582 additions and 154 deletions.
184 changes: 47 additions & 137 deletions lib/web/fetch/body.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
'use strict'

const Busboy = require('@fastify/busboy')
const util = require('../../core/util')
const {
ReadableStreamFrom,
Expand All @@ -9,23 +8,20 @@ const {
readableStreamClose,
createDeferredPromise,
fullyReadBody,
extractMimeType
extractMimeType,
utf8DecodeBytes
} = require('./util')
const { FormData } = require('./formdata')
const { kState } = require('./symbols')
const { webidl } = require('./webidl')
const { Blob, File: NativeFile } = require('node:buffer')
const { Blob } = require('node:buffer')
const assert = require('node:assert')
const { isErrored } = require('../../core/util')
const { isArrayBuffer } = require('node:util/types')
const { File: UndiciFile } = require('./file')
const { serializeAMimeType } = require('./data-url')
const { Readable } = require('node:stream')
const { multipartFormDataParser } = require('./formdata-parser')

/** @type {globalThis['File']} */
const File = NativeFile ?? UndiciFile
const textEncoder = new TextEncoder()
const textDecoder = new TextDecoder()

// https://fetch.spec.whatwg.org/#concept-bodyinit-extract
function extractBody (object, keepalive = false) {
Expand Down Expand Up @@ -342,116 +338,56 @@ function bodyMixinMethods (instance) {
return consumeBody(this, parseJSONFromBytes, instance)
},

async formData () {
webidl.brandCheck(this, instance)

throwIfAborted(this[kState])

// 1. Let mimeType be the result of get the MIME type with this.
const mimeType = bodyMimeType(this)

// If mimeType’s essence is "multipart/form-data", then:
if (mimeType !== null && mimeType.essence === 'multipart/form-data') {
const responseFormData = new FormData()

let busboy

try {
busboy = new Busboy({
headers: {
'content-type': serializeAMimeType(mimeType)
},
preservePath: true
})
} catch (err) {
throw new DOMException(`${err}`, 'AbortError')
}

busboy.on('field', (name, value) => {
responseFormData.append(name, value)
})
busboy.on('file', (name, value, filename, encoding, mimeType) => {
const chunks = []

if (encoding === 'base64' || encoding.toLowerCase() === 'base64') {
let base64chunk = ''

value.on('data', (chunk) => {
base64chunk += chunk.toString().replace(/[\r\n]/gm, '')

const end = base64chunk.length - base64chunk.length % 4
chunks.push(Buffer.from(base64chunk.slice(0, end), 'base64'))

base64chunk = base64chunk.slice(end)
})
value.on('end', () => {
chunks.push(Buffer.from(base64chunk, 'base64'))
responseFormData.append(name, new File(chunks, filename, { type: mimeType }))
})
} else {
value.on('data', (chunk) => {
chunks.push(chunk)
})
value.on('end', () => {
responseFormData.append(name, new File(chunks, filename, { type: mimeType }))
})
}
})

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

if (this.body !== null) {
Readable.from(this[kState].body.stream).pipe(busboy)
}
formData () {
// The formData() method steps are to return the result of running
// consume body with this and the following step given a byte sequence bytes:
return consumeBody(this, (value) => {
// 1. Let mimeType be the result of get the MIME type with this.
const mimeType = bodyMimeType(this)

// 2. If mimeType is non-null, then switch on mimeType’s essence and run
// the corresponding steps:
if (mimeType !== null) {
switch (mimeType.essence) {
case 'multipart/form-data': {
// 1. ... [long step]
const parsed = multipartFormDataParser(value, mimeType)

// 2. If that fails for some reason, then throw a TypeError.
if (parsed === 'failure') {
throw new TypeError('Failed to parse body as FormData.')
}

// 3. Return a new FormData object, appending each entry,
// resulting from the parsing operation, to its entry list.
const fd = new FormData()
fd[kState] = parsed

return fd
}
case 'application/x-www-form-urlencoded': {
// 1. Let entries be the result of parsing bytes.
const entries = new URLSearchParams(value.toString())

await busboyResolve
// 2. If entries is failure, then throw a TypeError.

return responseFormData
} else if (mimeType !== null && mimeType.essence === 'application/x-www-form-urlencoded') {
// Otherwise, if mimeType’s essence is "application/x-www-form-urlencoded", then:
// 3. Return a new FormData object whose entry list is entries.
const fd = new FormData()

// 1. Let entries be the result of parsing bytes.
let entries
try {
let text = ''
// application/x-www-form-urlencoded parser will keep the BOM.
// https://url.spec.whatwg.org/#concept-urlencoded-parser
// Note that streaming decoder is stateful and cannot be reused
const stream = this[kState].body.stream.pipeThrough(new TextDecoderStream('utf-8', { ignoreBOM: true }))
for (const [name, value] of entries) {
fd.append(name, value)
}

for await (const chunk of stream) {
text += chunk
return fd
}
}

entries = new URLSearchParams(text)
} catch (err) {
// istanbul ignore next: Unclear when new URLSearchParams can fail on a string.
// 2. If entries is failure, then throw a TypeError.
throw new TypeError(err)
}

// 3. Return a new FormData object whose entries are entries.
const formData = new FormData()
for (const [name, value] of entries) {
formData.append(name, value)
}
return formData
} else {
// Wait a tick before checking if the request has been aborted.
// Otherwise, a TypeError can be thrown when an AbortError should.
await Promise.resolve()

throwIfAborted(this[kState])

// Otherwise, throw a TypeError.
throw webidl.errors.exception({
header: `${instance.name}.formData`,
message: 'Could not parse content as FormData.'
})
}
// 3. Throw a TypeError.
throw new TypeError(
'Content-Type was not one of "multipart/form-data" or "application/x-www-form-urlencoded".'
)
}, instance)
}
}

Expand Down Expand Up @@ -520,32 +456,6 @@ function bodyUnusable (body) {
return body != null && (body.stream.locked || util.isDisturbed(body.stream))
}

/**
* @see https://encoding.spec.whatwg.org/#utf-8-decode
* @param {Buffer} buffer
*/
function utf8DecodeBytes (buffer) {
if (buffer.length === 0) {
return ''
}

// 1. Let buffer be the result of peeking three bytes from
// ioQueue, converted to a byte sequence.

// 2. If buffer is 0xEF 0xBB 0xBF, then read three
// bytes from ioQueue. (Do nothing with those bytes.)
if (buffer[0] === 0xEF && buffer[1] === 0xBB && buffer[2] === 0xBF) {
buffer = buffer.subarray(3)
}

// 3. Process a queue with an instance of UTF-8’s
// decoder, ioQueue, output, and "replacement".
const output = textDecoder.decode(buffer)

// 4. Return output.
return output
}

/**
* @see https://infra.spec.whatwg.org/#parse-json-bytes-to-a-javascript-value
* @param {Uint8Array} bytes
Expand Down
5 changes: 3 additions & 2 deletions lib/web/fetch/data-url.js
Original file line number Diff line number Diff line change
Expand Up @@ -628,7 +628,6 @@ function removeASCIIWhitespace (str, leading = true, trailing = true) {
}

/**
*
* @param {string} str
* @param {boolean} leading
* @param {boolean} trailing
Expand Down Expand Up @@ -738,5 +737,7 @@ module.exports = {
collectAnHTTPQuotedString,
serializeAMimeType,
removeChars,
minimizeSupportedMimeType
minimizeSupportedMimeType,
HTTP_TOKEN_CODEPOINTS,
isomorphicDecode
}
Loading

0 comments on commit fd7ab4f

Please sign in to comment.