Skip to content

Commit bdd6797

Browse files
committed
chore: merge 'fix/form-data-in-response' into main
2 parents 25324ce + dc5be05 commit bdd6797

File tree

8 files changed

+394
-188
lines changed

8 files changed

+394
-188
lines changed

@types/index.d.ts

+1
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ interface RequestInit {
8585
protocol?: string;
8686
size?: number;
8787
highWaterMark?: number;
88+
insecureHTTPParser?: boolean;
8889
}
8990

9091
interface ResponseInit {

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
},
5757
"devDependencies": {
5858
"abort-controller": "^3.0.0",
59-
"abortcontroller-polyfill": "^1.5.0",
59+
"abortcontroller-polyfill": "^1.7.1",
6060
"busboy": "^0.3.1",
6161
"c8": "^7.3.0",
6262
"chai": "^4.2.0",

src/body.js

+3-2
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export default class Body {
6262
}
6363

6464
this[INTERNALS] = {
65+
/** @type {Stream|Buffer|Blob|null} */
6566
body,
6667
boundary,
6768
disturbed: false,
@@ -198,7 +199,7 @@ async function consumeBody(data) {
198199

199200
try {
200201
for await (const chunk of body) {
201-
const bytes = typeof chunk === "string" ? Buffer.from(chunk) : chunk
202+
const bytes = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
202203
if (data.size > 0 && accumBytes + bytes.byteLength > data.size) {
203204
const err = new FetchError(`content size at ${data.url} over limit: ${data.size}`, 'max-size');
204205
body.destroy(err);
@@ -321,7 +322,7 @@ export const extractContentType = (body, request) => {
321322
*
322323
* ref: https://fetch.spec.whatwg.org/#concept-body-total-bytes
323324
*
324-
* @param {any} obj.body Body object from the Body instance.
325+
* @param {Body} request Body object from the Body instance.
325326
* @returns {number | null}
326327
*/
327328
export const getTotalBytes = request => {

src/index.js

+52
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,30 @@ export default async function fetch(url, options_) {
9595
finalize();
9696
});
9797

98+
fixResponseChunkedTransferBadEnding(request_, err => {
99+
response.body.destroy(err);
100+
});
101+
102+
/* c8 ignore next 18 */
103+
if (process.version < 'v14') {
104+
// Before Node.js 14, pipeline() does not fully support async iterators and does not always
105+
// properly handle when the socket close/end events are out of order.
106+
request_.on('socket', s => {
107+
let endedWithEventsCount;
108+
s.prependListener('end', () => {
109+
endedWithEventsCount = s._eventsCount;
110+
});
111+
s.prependListener('close', hadError => {
112+
// if end happened before close but the socket didn't emit an error, do it now
113+
if (response && endedWithEventsCount < s._eventsCount && !hadError) {
114+
const err = new Error('Premature close');
115+
err.code = 'ERR_STREAM_PREMATURE_CLOSE';
116+
response.body.emit('error', err);
117+
}
118+
});
119+
});
120+
}
121+
98122
request_.on('response', response_ => {
99123
request_.setTimeout(0);
100124
const headers = fromRawHeaders(response_.rawHeaders);
@@ -265,3 +289,31 @@ export default async function fetch(url, options_) {
265289
writeToStream(request_, request);
266290
});
267291
}
292+
293+
function fixResponseChunkedTransferBadEnding(request, errorCallback) {
294+
const LAST_CHUNK = Buffer.from('0\r\n');
295+
let socket;
296+
297+
request.on('socket', s => {
298+
socket = s;
299+
});
300+
301+
request.on('response', response => {
302+
const {headers} = response;
303+
if (headers['transfer-encoding'] === 'chunked' && !headers['content-length']) {
304+
let properLastChunkReceived = false;
305+
306+
socket.on('data', buf => {
307+
properLastChunkReceived = Buffer.compare(buf.slice(-3), LAST_CHUNK) === 0;
308+
});
309+
310+
socket.prependListener('close', () => {
311+
if (!properLastChunkReceived) {
312+
const err = new Error('Premature close');
313+
err.code = 'ERR_STREAM_PREMATURE_CLOSE';
314+
errorCallback(err);
315+
}
316+
});
317+
}
318+
});
319+
}

src/utils/is.js

+4-4
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const NAME = Symbol.toStringTag;
1111
* ref: https://github.com/node-fetch/node-fetch/issues/296#issuecomment-307598143
1212
*
1313
* @param {*} obj
14-
* @return {boolean}
14+
* @return {object is URLSearchParams}
1515
*/
1616
export const isURLSearchParameters = object => {
1717
return (
@@ -31,7 +31,7 @@ export const isURLSearchParameters = object => {
3131
* Check if `object` is a W3C `Blob` object (which `File` inherits from)
3232
*
3333
* @param {*} obj
34-
* @return {boolean}
34+
* @return {object is Blob}
3535
*/
3636
export const isBlob = object => {
3737
return (
@@ -52,7 +52,7 @@ export const isBlob = object => {
5252
* Check if `obj` is a spec-compliant `FormData` object
5353
*
5454
* @param {*} object
55-
* @return {boolean}
55+
* @return {object is FormData}
5656
*/
5757
export function isFormData(object) {
5858
return (
@@ -74,7 +74,7 @@ export function isFormData(object) {
7474
* Check if `obj` is an instance of AbortSignal.
7575
*
7676
* @param {*} obj
77-
* @return {boolean}
77+
* @return {object is AbortSignal}
7878
*/
7979
export const isAbortSignal = object => {
8080
return (

test/form-data.js

+23-6
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import FormData from 'formdata-node';
22
import Blob from 'fetch-blob';
3-
import { Response } from '../src/index.js';
3+
import {Response, Request} from '../src/index.js';
4+
import {getTotalBytes} from '../src/body.js';
45

56
import chai from 'chai';
67

@@ -103,13 +104,29 @@ describe('FormData', () => {
103104
expect(String(await read(formDataIterator(form, boundary)))).to.be.equal(expected);
104105
});
105106

106-
it('Response derives content-type from FormData', async () => {
107+
it('Response supports FormData body', async () => {
107108
const form = new FormData();
108109
form.set('blob', new Blob(['Hello, World!'], {type: 'text/plain'}));
109110

110-
const response = new Response(form)
111-
const type = response.headers.get('content-type') || ''
112-
expect(type).to.match(/multipart\/form-data;\s*boundary=/)
113-
expect(await response.text()).to.have.string('Hello, World!')
111+
const response = new Response(form);
112+
const type = response.headers.get('content-type') || '';
113+
expect(type).to.match(/multipart\/form-data;\s*boundary=/);
114+
expect(await response.text()).to.have.string('Hello, World!');
115+
// Note: getTotalBytes assumes body could be form data but it never is
116+
// because it gets normalized into a stream.
117+
expect(getTotalBytes({...response, body: form})).to.be.greaterThan(20);
118+
});
119+
120+
it('Request supports FormData body', async () => {
121+
const form = new FormData();
122+
form.set('blob', new Blob(['Hello, World!'], {type: 'text/plain'}));
123+
124+
const request = new Request('https://github.com/node-fetch/', {
125+
body: form,
126+
method: 'POST'
127+
});
128+
const type = request.headers.get('content-type') || '';
129+
expect(type).to.match(/multipart\/form-data;\s*boundary=/);
130+
expect(await request.text()).to.have.string('Hello, World!');
114131
});
115132
});

0 commit comments

Comments
 (0)