Skip to content

Commit 86262a6

Browse files
MichaelDeBoeyBenMcHjacob-ebey
authored
feat(fetch): backport node-fetch's redirect bugfix (#77)
* Backport node-fetch redirect bugfix node-fetch PR that this work is based upon: node-fetch/node-fetch#1222 Co-authored-by: Jacob Ebey <[email protected]> * chore: fix changeset --------- Co-authored-by: Ben <[email protected]> Co-authored-by: Jacob Ebey <[email protected]>
1 parent fd274bd commit 86262a6

File tree

4 files changed

+97
-18
lines changed

4 files changed

+97
-18
lines changed

.changeset/folk-metal-music.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@web-std/fetch": minor
3+
---
4+
5+
Fixes redirects failing when response is chunked but empty. This is backported from https://github.com/node-fetch/node-fetch/pull/1222

packages/fetch/src/fetch.js

+36-18
Original file line numberDiff line numberDiff line change
@@ -324,29 +324,47 @@ async function fetch(url, options_ = {}) {
324324
* @param {(error:Error) => void} errorCallback
325325
*/
326326
function fixResponseChunkedTransferBadEnding(request, errorCallback) {
327-
/** @type {import('net').Socket} */
328-
let socket;
327+
const LAST_CHUNK = Buffer.from('0\r\n\r\n');
329328

330-
request.on('socket', s => {
331-
socket = s;
332-
});
329+
let isChunkedTransfer = false;
330+
let properLastChunkReceived = false;
331+
/** @type {Buffer | undefined} */
332+
let previousChunk;
333333

334334
request.on('response', response => {
335-
336335
const {headers} = response;
336+
isChunkedTransfer = headers['transfer-encoding'] === 'chunked' && !headers['content-length'];
337+
});
337338

338-
if (headers['transfer-encoding'] === 'chunked' && !headers['content-length']) {
339-
socket.prependListener('close', hadError => {
340-
// if a data listener is still present we didn't end cleanly
341-
const hasDataListener = socket.listenerCount('data') > 0;
342-
if (hasDataListener && !hadError) {
343-
const err = Object.assign(new Error('Premature close'), {
344-
code: 'ERR_STREAM_PREMATURE_CLOSE'
345-
})
346-
errorCallback(err);
347-
}
348-
});
349-
}
339+
request.on('socket', socket => {
340+
const onSocketClose = () => {
341+
if (isChunkedTransfer && !properLastChunkReceived) {
342+
const error = Object.assign(new Error('Premature close'), {
343+
code: 'ERR_STREAM_PREMATURE_CLOSE'
344+
});
345+
errorCallback(error);
346+
}
347+
};
348+
349+
socket.prependListener('close', onSocketClose);
350+
351+
request.on('abort', () => {
352+
socket.removeListener('close', onSocketClose);
353+
});
354+
355+
socket.on('data', buf => {
356+
properLastChunkReceived = Buffer.compare(buf.slice(-5), LAST_CHUNK) === 0;
357+
358+
// Sometimes final 0-length chunk and end of message code are in separate packets
359+
if (!properLastChunkReceived && previousChunk) {
360+
properLastChunkReceived = (
361+
Buffer.compare(previousChunk.slice(-3), LAST_CHUNK.slice(0, 3)) === 0 &&
362+
Buffer.compare(buf.slice(-2), LAST_CHUNK.slice(3)) === 0
363+
);
364+
}
365+
366+
previousChunk = buf;
367+
});
350368
});
351369
}
352370

packages/fetch/test/main.js

+30
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,36 @@ describe('node-fetch', () => {
665665
});
666666
});
667667

668+
it('should follow redirect after empty chunked transfer-encoding', () => {
669+
const url = `${base}redirect/chunked`;
670+
return fetch(url).then(res => {
671+
expect(res.status).to.equal(200);
672+
expect(res.ok).to.be.true;
673+
});
674+
});
675+
676+
it('should handle chunked response with more than 1 chunk in the final packet', () => {
677+
const url = `${base}chunked/multiple-ending`;
678+
return fetch(url).then(res => {
679+
expect(res.ok).to.be.true;
680+
681+
return res.text().then(result => {
682+
expect(result).to.equal('foobar');
683+
});
684+
});
685+
});
686+
687+
it('should handle chunked response with final chunk and EOM in separate packets', () => {
688+
const url = `${base}chunked/split-ending`;
689+
return fetch(url).then(res => {
690+
expect(res.ok).to.be.true;
691+
692+
return res.text().then(result => {
693+
expect(result).to.equal('foobar');
694+
});
695+
});
696+
});
697+
668698
it.skip('should handle DNS-error response', () => {
669699
const url = 'http://domain.invalid';
670700
return expect(fetch(url)).to.eventually.be.rejected

packages/fetch/test/utils/server.js

+26
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,14 @@ export default class TestServer {
310310
res.socket.end('\r\n');
311311
}
312312

313+
if (p === '/redirect/chunked') {
314+
res.writeHead(301, {
315+
Location: '/inspect',
316+
'Transfer-Encoding': 'chunked'
317+
});
318+
setTimeout(() => res.end(), 10);
319+
}
320+
313321
if (p === '/error/400') {
314322
res.statusCode = 400;
315323
res.setHeader('Content-Type', 'text/plain');
@@ -357,6 +365,24 @@ export default class TestServer {
357365
}, 400);
358366
}
359367

368+
if (p === '/chunked/split-ending') {
369+
res.socket.write('HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\n\r\n');
370+
res.socket.write('3\r\nfoo\r\n3\r\nbar\r\n');
371+
372+
setTimeout(() => {
373+
res.socket.write('0\r\n');
374+
}, 10);
375+
376+
setTimeout(() => {
377+
res.socket.end('\r\n');
378+
}, 20);
379+
}
380+
381+
if (p === '/chunked/multiple-ending') {
382+
res.socket.write('HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\n\r\n');
383+
res.socket.write('3\r\nfoo\r\n3\r\nbar\r\n0\r\n\r\n');
384+
}
385+
360386
if (p === '/error/json') {
361387
res.statusCode = 200;
362388
res.setHeader('Content-Type', 'application/json');

0 commit comments

Comments
 (0)