diff --git a/@types/index.d.ts b/@types/index.d.ts index c7207c435..9f70902e2 100644 --- a/@types/index.d.ts +++ b/@types/index.d.ts @@ -103,6 +103,7 @@ export type BodyInit = | Blob | Buffer | URLSearchParams + | FormData | NodeJS.ReadableStream | string; declare class BodyMixin { @@ -117,6 +118,7 @@ declare class BodyMixin { */ buffer(): Promise; arrayBuffer(): Promise; + formData(): Promise; blob(): Promise; json(): Promise; text(): Promise; diff --git a/README.md b/README.md index 1a7466276..cf89579c6 100644 --- a/README.md +++ b/README.md @@ -731,6 +731,8 @@ A boolean property for if this body has been consumed. Per the specs, a consumed #### body.arrayBuffer() +#### body.formData() + #### body.blob() #### body.json() @@ -743,14 +745,6 @@ A boolean property for if this body has been consumed. Per the specs, a consumed Consume the body and return a promise that will resolve to one of these formats. -#### body.buffer() - -_(node-fetch extension)_ - -- Returns: `Promise` - -Consume the body and return a promise that will resolve to a Buffer. - ### Class: FetchError diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 781801b4f..3825525f3 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## unreleased - other: Deprecated/Discourage [form-data](https://www.npmjs.com/package/form-data) and `body.buffer()` (#1212) +- feat: Add `Body#formData()` (#1314) - fix: Normalize `Body.body` into a `node:stream` (#924) - fix: Pass url string to `http.request` for parsing IPv6 urls (#1268) - fix: Throw error when constructing Request with urls including basic auth (#1268) diff --git a/package.json b/package.json index 189b1818b..1e1bad58a 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "coveralls": "^3.1.0", "delay": "^5.0.0", "form-data": "^4.0.0", - "formdata-node": "^3.5.4", + "formdata-node": "^4.2.4", "mocha": "^9.1.3", "p-timeout": "^5.0.0", "tsd": "^0.14.0", @@ -63,6 +63,7 @@ }, "dependencies": { "data-uri-to-buffer": "^4.0.0", + "formdata-polyfill": "^4.0.10", "fetch-blob": "^3.1.2" }, "tsd": { @@ -91,6 +92,7 @@ "unicorn/numeric-separators-style": 0, "unicorn/explicit-length-check": 0, "capitalized-comments": 0, + "node/no-unsupported-features/es-syntax": 0, "@typescript-eslint/member-ordering": 0 }, "overrides": [ diff --git a/src/body.js b/src/body.js index 83357f6c2..85a8ea55a 100644 --- a/src/body.js +++ b/src/body.js @@ -9,11 +9,11 @@ import Stream, {PassThrough} from 'node:stream'; import {types, deprecate} from 'node:util'; import Blob from 'fetch-blob'; +import {FormData, formDataToBlob} from 'formdata-polyfill/esm.min.js'; import {FetchError} from './errors/fetch-error.js'; import {FetchBaseError} from './errors/base.js'; -import {formDataIterator, getBoundary, getFormDataLength} from './utils/form-data.js'; -import {isBlob, isURLSearchParameters, isFormData} from './utils/is.js'; +import {isBlob, isURLSearchParameters} from './utils/is.js'; const INTERNALS = Symbol('Body internals'); @@ -50,10 +50,10 @@ export default class Body { body = Buffer.from(body.buffer, body.byteOffset, body.byteLength); } else if (body instanceof Stream) { // Body is stream - } else if (isFormData(body)) { - // Body is an instance of formdata-node - boundary = `nodefetchformdataboundary${getBoundary()}`; - body = Stream.Readable.from(formDataIterator(body, boundary)); + } else if (body instanceof FormData) { + // Body is FormData + body = formDataToBlob(body); + boundary = body.type.split('=')[1]; } else { // None of the above // coerce to string then buffer @@ -105,6 +105,24 @@ export default class Body { return buffer.slice(byteOffset, byteOffset + byteLength); } + async formData() { + const ct = this.headers.get('content-type'); + + if (ct.startsWith('application/x-www-form-urlencoded')) { + const formData = new FormData(); + const parameters = new URLSearchParams(await this.text()); + + for (const [name, value] of parameters) { + formData.append(name, value); + } + + return formData; + } + + const {toFormData} = await import('./utils/multipart-parser.js'); + return toFormData(this.body, ct); + } + /** * Return raw response as Blob * @@ -302,7 +320,7 @@ export const extractContentType = (body, request) => { return null; } - if (isFormData(body)) { + if (body instanceof FormData) { return `multipart/form-data; boundary=${request[INTERNALS].boundary}`; } @@ -352,11 +370,6 @@ export const getTotalBytes = request => { return body.hasKnownLength && body.hasKnownLength() ? body.getLengthSync() : null; } - // Body is a spec-compliant FormData - if (isFormData(body)) { - return getFormDataLength(request[INTERNALS].boundary); - } - // Body is stream return null; }; diff --git a/src/response.js b/src/response.js index eaba9a9e1..63af26711 100644 --- a/src/response.js +++ b/src/response.js @@ -29,7 +29,7 @@ export default class Response extends Body { const headers = new Headers(options.headers); if (body !== null && !headers.has('Content-Type')) { - const contentType = extractContentType(body); + const contentType = extractContentType(body, this); if (contentType) { headers.append('Content-Type', contentType); } diff --git a/src/utils/form-data.js b/src/utils/form-data.js deleted file mode 100644 index ba0c14ac5..000000000 --- a/src/utils/form-data.js +++ /dev/null @@ -1,78 +0,0 @@ -import {randomBytes} from 'node:crypto'; - -import {isBlob} from './is.js'; - -const carriage = '\r\n'; -const dashes = '-'.repeat(2); -const carriageLength = Buffer.byteLength(carriage); - -/** - * @param {string} boundary - */ -const getFooter = boundary => `${dashes}${boundary}${dashes}${carriage.repeat(2)}`; - -/** - * @param {string} boundary - * @param {string} name - * @param {*} field - * - * @return {string} - */ -function getHeader(boundary, name, field) { - let header = ''; - - header += `${dashes}${boundary}${carriage}`; - header += `Content-Disposition: form-data; name="${name}"`; - - if (isBlob(field)) { - header += `; filename="${field.name}"${carriage}`; - header += `Content-Type: ${field.type || 'application/octet-stream'}`; - } - - return `${header}${carriage.repeat(2)}`; -} - -/** - * @return {string} - */ -export const getBoundary = () => randomBytes(8).toString('hex'); - -/** - * @param {FormData} form - * @param {string} boundary - */ -export async function * formDataIterator(form, boundary) { - for (const [name, value] of form) { - yield getHeader(boundary, name, value); - - if (isBlob(value)) { - yield * value.stream(); - } else { - yield value; - } - - yield carriage; - } - - yield getFooter(boundary); -} - -/** - * @param {FormData} form - * @param {string} boundary - */ -export function getFormDataLength(form, boundary) { - let length = 0; - - for (const [name, value] of form) { - length += Buffer.byteLength(getHeader(boundary, name, value)); - - length += isBlob(value) ? value.size : Buffer.byteLength(String(value)); - - length += carriageLength; - } - - length += Buffer.byteLength(getFooter(boundary)); - - return length; -} diff --git a/src/utils/is.js b/src/utils/is.js index d23b9f027..377161ff1 100644 --- a/src/utils/is.js +++ b/src/utils/is.js @@ -9,8 +9,7 @@ const NAME = Symbol.toStringTag; /** * Check if `obj` is a URLSearchParams object * ref: https://github.com/node-fetch/node-fetch/issues/296#issuecomment-307598143 - * - * @param {*} obj + * @param {*} object - Object to check for * @return {boolean} */ export const isURLSearchParameters = object => { @@ -29,8 +28,7 @@ export const isURLSearchParameters = object => { /** * Check if `object` is a W3C `Blob` object (which `File` inherits from) - * - * @param {*} obj + * @param {*} object - Object to check for * @return {boolean} */ export const isBlob = object => { @@ -45,32 +43,9 @@ export const isBlob = object => { ); }; -/** - * Check if `obj` is a spec-compliant `FormData` object - * - * @param {*} object - * @return {boolean} - */ -export function isFormData(object) { - return ( - typeof object === 'object' && - typeof object.append === 'function' && - typeof object.set === 'function' && - typeof object.get === 'function' && - typeof object.getAll === 'function' && - typeof object.delete === 'function' && - typeof object.keys === 'function' && - typeof object.values === 'function' && - typeof object.entries === 'function' && - typeof object.constructor === 'function' && - object[NAME] === 'FormData' - ); -} - /** * Check if `obj` is an instance of AbortSignal. - * - * @param {*} obj + * @param {*} object - Object to check for * @return {boolean} */ export const isAbortSignal = object => { @@ -81,4 +56,3 @@ export const isAbortSignal = object => { ) ); }; - diff --git a/src/utils/multipart-parser.js b/src/utils/multipart-parser.js new file mode 100644 index 000000000..5ad06f98e --- /dev/null +++ b/src/utils/multipart-parser.js @@ -0,0 +1,432 @@ +import {File} from 'fetch-blob/from.js'; +import {FormData} from 'formdata-polyfill/esm.min.js'; + +let s = 0; +const S = { + START_BOUNDARY: s++, + HEADER_FIELD_START: s++, + HEADER_FIELD: s++, + HEADER_VALUE_START: s++, + HEADER_VALUE: s++, + HEADER_VALUE_ALMOST_DONE: s++, + HEADERS_ALMOST_DONE: s++, + PART_DATA_START: s++, + PART_DATA: s++, + END: s++ +}; + +let f = 1; +const F = { + PART_BOUNDARY: f, + LAST_BOUNDARY: f *= 2 +}; + +const LF = 10; +const CR = 13; +const SPACE = 32; +const HYPHEN = 45; +const COLON = 58; +const A = 97; +const Z = 122; + +const lower = c => c | 0x20; + +const noop = () => {}; + +class MultipartParser { + /** + * @param {string} boundary + */ + constructor(boundary) { + this.index = 0; + this.flags = 0; + + this.onHeaderEnd = noop; + this.onHeaderField = noop; + this.onHeadersEnd = noop; + this.onHeaderValue = noop; + this.onPartBegin = noop; + this.onPartData = noop; + this.onPartEnd = noop; + + this.boundaryChars = {}; + + boundary = '\r\n--' + boundary; + const ui8a = new Uint8Array(boundary.length); + for (let i = 0; i < boundary.length; i++) { + ui8a[i] = boundary.charCodeAt(i); + this.boundaryChars[ui8a[i]] = true; + } + + this.boundary = ui8a; + this.lookbehind = new Uint8Array(this.boundary.length + 8); + this.state = S.START_BOUNDARY; + } + + /** + * @param {Uint8Array} data + */ + write(data) { + let i = 0; + const length_ = data.length; + let previousIndex = this.index; + let {lookbehind, boundary, boundaryChars, index, state, flags} = this; + const boundaryLength = this.boundary.length; + const boundaryEnd = boundaryLength - 1; + const bufferLength = data.length; + let c; + let cl; + + const mark = name => { + this[name + 'Mark'] = i; + }; + + const clear = name => { + delete this[name + 'Mark']; + }; + + const callback = (callbackSymbol, start, end, ui8a) => { + if (start === undefined || start !== end) { + this[callbackSymbol](ui8a && ui8a.subarray(start, end)); + } + }; + + const dataCallback = (name, clear) => { + const markSymbol = name + 'Mark'; + if (!(markSymbol in this)) { + return; + } + + if (clear) { + callback(name, this[markSymbol], i, data); + delete this[markSymbol]; + } else { + callback(name, this[markSymbol], data.length, data); + this[markSymbol] = 0; + } + }; + + for (i = 0; i < length_; i++) { + c = data[i]; + + switch (state) { + case S.START_BOUNDARY: + if (index === boundary.length - 2) { + if (c === HYPHEN) { + flags |= F.LAST_BOUNDARY; + } else if (c !== CR) { + return; + } + + index++; + break; + } else if (index - 1 === boundary.length - 2) { + if (flags & F.LAST_BOUNDARY && c === HYPHEN) { + state = S.END; + flags = 0; + } else if (!(flags & F.LAST_BOUNDARY) && c === LF) { + index = 0; + callback('onPartBegin'); + state = S.HEADER_FIELD_START; + } else { + return; + } + + break; + } + + if (c !== boundary[index + 2]) { + index = -2; + } + + if (c === boundary[index + 2]) { + index++; + } + + break; + case S.HEADER_FIELD_START: + state = S.HEADER_FIELD; + mark('onHeaderField'); + index = 0; + // falls through + case S.HEADER_FIELD: + if (c === CR) { + clear('onHeaderField'); + state = S.HEADERS_ALMOST_DONE; + break; + } + + index++; + if (c === HYPHEN) { + break; + } + + if (c === COLON) { + if (index === 1) { + // empty header field + return; + } + + dataCallback('onHeaderField', true); + state = S.HEADER_VALUE_START; + break; + } + + cl = lower(c); + if (cl < A || cl > Z) { + return; + } + + break; + case S.HEADER_VALUE_START: + if (c === SPACE) { + break; + } + + mark('onHeaderValue'); + state = S.HEADER_VALUE; + // falls through + case S.HEADER_VALUE: + if (c === CR) { + dataCallback('onHeaderValue', true); + callback('onHeaderEnd'); + state = S.HEADER_VALUE_ALMOST_DONE; + } + + break; + case S.HEADER_VALUE_ALMOST_DONE: + if (c !== LF) { + return; + } + + state = S.HEADER_FIELD_START; + break; + case S.HEADERS_ALMOST_DONE: + if (c !== LF) { + return; + } + + callback('onHeadersEnd'); + state = S.PART_DATA_START; + break; + case S.PART_DATA_START: + state = S.PART_DATA; + mark('onPartData'); + // falls through + case S.PART_DATA: + previousIndex = index; + + if (index === 0) { + // boyer-moore derrived algorithm to safely skip non-boundary data + i += boundaryEnd; + while (i < bufferLength && !(data[i] in boundaryChars)) { + i += boundaryLength; + } + + i -= boundaryEnd; + c = data[i]; + } + + if (index < boundary.length) { + if (boundary[index] === c) { + if (index === 0) { + dataCallback('onPartData', true); + } + + index++; + } else { + index = 0; + } + } else if (index === boundary.length) { + index++; + if (c === CR) { + // CR = part boundary + flags |= F.PART_BOUNDARY; + } else if (c === HYPHEN) { + // HYPHEN = end boundary + flags |= F.LAST_BOUNDARY; + } else { + index = 0; + } + } else if (index - 1 === boundary.length) { + if (flags & F.PART_BOUNDARY) { + index = 0; + if (c === LF) { + // unset the PART_BOUNDARY flag + flags &= ~F.PART_BOUNDARY; + callback('onPartEnd'); + callback('onPartBegin'); + state = S.HEADER_FIELD_START; + break; + } + } else if (flags & F.LAST_BOUNDARY) { + if (c === HYPHEN) { + callback('onPartEnd'); + state = S.END; + flags = 0; + } else { + index = 0; + } + } else { + index = 0; + } + } + + if (index > 0) { + // when matching a possible boundary, keep a lookbehind reference + // in case it turns out to be a false lead + lookbehind[index - 1] = c; + } else if (previousIndex > 0) { + // if our boundary turned out to be rubbish, the captured lookbehind + // belongs to partData + const _lookbehind = new Uint8Array(lookbehind.buffer, lookbehind.byteOffset, lookbehind.byteLength); + callback('onPartData', 0, previousIndex, _lookbehind); + previousIndex = 0; + mark('onPartData'); + + // reconsider the current character even so it interrupted the sequence + // it could be the beginning of a new sequence + i--; + } + + break; + case S.END: + break; + default: + throw new Error(`Unexpected state entered: ${state}`); + } + } + + dataCallback('onHeaderField'); + dataCallback('onHeaderValue'); + dataCallback('onPartData'); + + // Update properties for the next call + this.index = index; + this.state = state; + this.flags = flags; + } + + end() { + if ((this.state === S.HEADER_FIELD_START && this.index === 0) || + (this.state === S.PART_DATA && this.index === this.boundary.length)) { + this.onPartEnd(); + } else if (this.state !== S.END) { + throw new Error('MultipartParser.end(): stream ended unexpectedly'); + } + } +} + +function _fileName(headerValue) { + // matches either a quoted-string or a token (RFC 2616 section 19.5.1) + const m = headerValue.match(/\bfilename=("(.*?)"|([^()<>@,;:\\"/[\]?={}\s\t]+))($|;\s)/i); + if (!m) { + return; + } + + const match = m[2] || m[3] || ''; + let filename = match.slice(match.lastIndexOf('\\') + 1); + filename = filename.replace(/%22/g, '"'); + filename = filename.replace(/&#(\d{4});/g, (m, code) => { + return String.fromCharCode(code); + }); + return filename; +} + +export async function toFormData(Body, ct) { + if (!/multipart/i.test(ct)) { + throw new TypeError('Failed to fetch'); + } + + const m = ct.match(/boundary=(?:"([^"]+)"|([^;]+))/i); + + if (!m) { + throw new TypeError('no or bad content-type header, no multipart boundary'); + } + + const parser = new MultipartParser(m[1] || m[2]); + + let headerField; + let headerValue; + let entryValue; + let entryName; + let contentType; + let filename; + const entryChunks = []; + const formData = new FormData(); + + const onPartData = ui8a => { + entryValue += decoder.decode(ui8a, {stream: true}); + }; + + const appendToFile = ui8a => { + entryChunks.push(ui8a); + }; + + const appendFileToFormData = () => { + const file = new File(entryChunks, filename, {type: contentType}); + formData.append(entryName, file); + }; + + const appendEntryToFormData = () => { + formData.append(entryName, entryValue); + }; + + const decoder = new TextDecoder('utf-8'); + decoder.decode(); + + parser.onPartBegin = function () { + parser.onPartData = onPartData; + parser.onPartEnd = appendEntryToFormData; + + headerField = ''; + headerValue = ''; + entryValue = ''; + entryName = ''; + contentType = ''; + filename = null; + entryChunks.length = 0; + }; + + parser.onHeaderField = function (ui8a) { + headerField += decoder.decode(ui8a, {stream: true}); + }; + + parser.onHeaderValue = function (ui8a) { + headerValue += decoder.decode(ui8a, {stream: true}); + }; + + parser.onHeaderEnd = function () { + headerValue += decoder.decode(); + headerField = headerField.toLowerCase(); + + if (headerField === 'content-disposition') { + // matches either a quoted-string or a token (RFC 2616 section 19.5.1) + const m = headerValue.match(/\bname=("([^"]*)"|([^()<>@,;:\\"/[\]?={}\s\t]+))/i); + + if (m) { + entryName = m[2] || m[3] || ''; + } + + filename = _fileName(headerValue); + + if (filename) { + parser.onPartData = appendToFile; + parser.onPartEnd = appendFileToFormData; + } + } else if (headerField === 'content-type') { + contentType = headerValue; + } + + headerValue = ''; + headerField = ''; + }; + + for await (const chunk of Body) { + parser.write(chunk); + } + + parser.end(); + + return formData; +} diff --git a/test/form-data.js b/test/form-data.js index f7f289197..9acbab948 100644 --- a/test/form-data.js +++ b/test/form-data.js @@ -1,103 +1,95 @@ -import {FormData} from 'formdata-node'; -import Blob from 'fetch-blob'; - +import {FormData as FormDataNode} from 'formdata-node'; +import {FormData} from 'formdata-polyfill/esm.min.js'; +import {Blob} from 'fetch-blob/from.js'; import chai from 'chai'; - -import {getFormDataLength, getBoundary, formDataIterator} from '../src/utils/form-data.js'; -import read from './utils/read-stream.js'; +import {Request, Response} from '../src/index.js'; const {expect} = chai; -const carriage = '\r\n'; - -const getFooter = boundary => `--${boundary}--${carriage.repeat(2)}`; - describe('FormData', () => { - it('should return a length for empty form-data', () => { - const form = new FormData(); - const boundary = getBoundary(); + it('Consume empty URLSearchParams as FormData', async () => { + const res = new Response(new URLSearchParams()); + const fd = await res.formData(); - expect(getFormDataLength(form, boundary)).to.be.equal(Buffer.byteLength(getFooter(boundary))); + expect(fd).to.be.instanceOf(FormData); }); - it('should add a Blob field\'s size to the FormData length', () => { - const form = new FormData(); - const boundary = getBoundary(); + it('Consume empty URLSearchParams as FormData', async () => { + const req = new Request('about:blank', { + method: 'POST', + body: new URLSearchParams() + }); + const fd = await req.formData(); - const string = 'Hello, world!'; - const expected = Buffer.byteLength( - `--${boundary}${carriage}` + - `Content-Disposition: form-data; name="field"${carriage.repeat(2)}` + - string + - `${carriage}${getFooter(boundary)}` - ); + expect(fd).to.be.instanceOf(FormData); + }); - form.set('field', string); + it('Consume empty response.formData() as FormData', async () => { + const res = new Response(new FormData()); + const fd = await res.formData(); - expect(getFormDataLength(form, boundary)).to.be.equal(expected); + expect(fd).to.be.instanceOf(FormData); }); - it('should return a length for a Blob field', () => { - const form = new FormData(); - const boundary = getBoundary(); - - const blob = new Blob(['Hello, world!'], {type: 'text/plain'}); + it('Consume empty response.formData() as FormData', async () => { + const res = new Response(new FormData()); + const fd = await res.formData(); - form.set('blob', blob); + expect(fd).to.be.instanceOf(FormData); + }); - const expected = blob.size + Buffer.byteLength( - `--${boundary}${carriage}` + - 'Content-Disposition: form-data; name="blob"; ' + - `filename="blob"${carriage}Content-Type: text/plain` + - `${carriage.repeat(3)}${getFooter(boundary)}` - ); + it('Consume empty request.formData() as FormData', async () => { + const req = new Request('about:blank', { + method: 'POST', + body: new FormData() + }); + const fd = await req.formData(); - expect(getFormDataLength(form, boundary)).to.be.equal(expected); + expect(fd).to.be.instanceOf(FormData); }); - it('should create a body from empty form-data', async () => { - const form = new FormData(); - const boundary = getBoundary(); + it('Consume URLSearchParams with entries as FormData', async () => { + const res = new Response(new URLSearchParams({foo: 'bar'})); + const fd = await res.formData(); - expect(String(await read(formDataIterator(form, boundary)))).to.be.equal(getFooter(boundary)); + expect(fd.get('foo')).to.be.equal('bar'); }); - it('should set default content-type', async () => { + it('should return a length for empty form-data', async () => { const form = new FormData(); - const boundary = getBoundary(); + const ab = await new Request('http://a', { + method: 'post', + body: form + }).arrayBuffer(); - form.set('blob', new Blob([])); - - expect(String(await read(formDataIterator(form, boundary)))).to.contain('Content-Type: application/octet-stream'); + expect(ab.byteLength).to.be.greaterThan(30); }); - it('should create a body with a FormData field', async () => { + it('should add a Blob field\'s size to the FormData length', async () => { const form = new FormData(); - const boundary = getBoundary(); - const string = 'Hello, World!'; - + const string = 'Hello, world!'; form.set('field', string); - - const expected = `--${boundary}${carriage}` + - `Content-Disposition: form-data; name="field"${carriage.repeat(2)}` + - string + - `${carriage}${getFooter(boundary)}`; - - expect(String(await read(formDataIterator(form, boundary)))).to.be.equal(expected); + const fd = await new Request('about:blank', {method: 'POST', body: form}).formData(); + expect(fd.get('field')).to.equal(string); }); - it('should create a body with a FormData Blob field', async () => { + it('should return a length for a Blob field', async () => { const form = new FormData(); - const boundary = getBoundary(); + const blob = new Blob(['Hello, world!'], {type: 'text/plain'}); + form.set('blob', blob); + + const fd = await new Response(form).formData(); - const expected = `--${boundary}${carriage}` + - 'Content-Disposition: form-data; name="blob"; ' + - `filename="blob"${carriage}Content-Type: text/plain${carriage.repeat(2)}` + - 'Hello, World!' + - `${carriage}${getFooter(boundary)}`; + expect(fd.get('blob').size).to.equal(13); + }); - form.set('blob', new Blob(['Hello, World!'], {type: 'text/plain'})); + it('FormData-node still works thanks to symbol.hasInstance', async () => { + const form = new FormDataNode(); + form.append('file', new Blob(['abc'], {type: 'text/html'})); + const res = new Response(form); + const fd = await res.formData(); - expect(String(await read(formDataIterator(form, boundary)))).to.be.equal(expected); + expect(await fd.get('file').text()).to.equal('abc'); + expect(fd.get('file').type).to.equal('text/html'); }); }); diff --git a/test/main.js b/test/main.js index c8ae86eab..dc4198d75 100644 --- a/test/main.js +++ b/test/main.js @@ -12,7 +12,7 @@ import chaiPromised from 'chai-as-promised'; import chaiIterator from 'chai-iterator'; import chaiString from 'chai-string'; import FormData from 'form-data'; -import {FormData as FormDataNode} from 'formdata-node'; +import {FormData as FormDataNode} from 'formdata-polyfill/esm.min.js'; import delay from 'delay'; import AbortControllerMysticatea from 'abort-controller'; import abortControllerPolyfill from 'abortcontroller-polyfill/dist/abortcontroller.js';