Skip to content

Commit 35722f1

Browse files
TimothyGugr2m
andcommitted
Significant rework of redirection
- Handle Location-less redirect like non-redirect response. - Include bodies when redirecting to non-POST 301/302 and all 307/308 response. Co-authored-by: Gregor Martynus <gregor@martynus.net>
1 parent fc53995 commit 35722f1

File tree

3 files changed

+118
-51
lines changed

3 files changed

+118
-51
lines changed

src/index.js

Lines changed: 60 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* a request API compatible with window.fetch
66
*/
77

8-
import Body, { writeToStream } from './body';
8+
import Body, { writeToStream, getTotalBytes } from './body';
99
import Response from './response';
1010
import Headers, { createHeadersLenient } from './headers';
1111
import Request, { getNodeRequestOptions } from './request';
@@ -67,49 +67,67 @@ export default function fetch(url, opts) {
6767
req.on('response', res => {
6868
clearTimeout(reqTimeout);
6969

70-
// handle redirect
71-
if (fetch.isRedirect(res.statusCode) && request.redirect !== 'manual') {
72-
if (request.redirect === 'error') {
73-
reject(new FetchError(`redirect mode is set to error: ${request.url}`, 'no-redirect'));
74-
return;
75-
}
76-
77-
if (request.counter >= request.follow) {
78-
reject(new FetchError(`maximum redirect reached at: ${request.url}`, 'max-redirect'));
79-
return;
80-
}
81-
82-
if (!res.headers.location) {
83-
reject(new FetchError(`redirect location header missing at: ${request.url}`, 'invalid-redirect'));
84-
return;
85-
}
70+
const headers = createHeadersLenient(res.headers);
8671

87-
// Create a new Request object.
88-
const requestOpts = {
89-
headers: new Headers(request.headers),
90-
follow: request.follow,
91-
counter: request.counter + 1,
92-
agent: request.agent,
93-
compress: request.compress,
94-
method: request.method
95-
};
96-
97-
// per fetch spec, for POST request with 301/302 response, or any request with 303 response, use GET when following redirect
98-
if (res.statusCode === 303
99-
|| ((res.statusCode === 301 || res.statusCode === 302) && request.method === 'POST'))
100-
{
101-
requestOpts.method = 'GET';
102-
requestOpts.headers.delete('content-length');
72+
// HTTP fetch step 5
73+
if (fetch.isRedirect(res.statusCode)) {
74+
// HTTP fetch step 5.2
75+
const location = headers.get('Location');
76+
77+
// HTTP fetch step 5.3
78+
const locationURL = location === null ? null : resolve_url(request.url, location);
79+
80+
// HTTP fetch step 5.5
81+
switch (request.redirect) {
82+
case 'error':
83+
reject(new FetchError(`redirect mode is set to error: ${request.url}`, 'no-redirect'));
84+
return;
85+
case 'manual':
86+
// node-fetch-specific step: make manual redirect a bit easier to use by setting the Location header value to the resolved URL.
87+
if (locationURL !== null) {
88+
headers.set('Location', locationURL);
89+
}
90+
break;
91+
case 'follow':
92+
// HTTP-redirect fetch step 2
93+
if (locationURL === null) {
94+
break;
95+
}
96+
97+
// HTTP-redirect fetch step 5
98+
if (request.counter >= request.follow) {
99+
reject(new FetchError(`maximum redirect reached at: ${request.url}`, 'max-redirect'));
100+
return;
101+
}
102+
103+
// HTTP-redirect fetch step 6 (counter increment)
104+
// Create a new Request object.
105+
const requestOpts = {
106+
headers: new Headers(request.headers),
107+
follow: request.follow,
108+
counter: request.counter + 1,
109+
agent: request.agent,
110+
compress: request.compress,
111+
method: request.method,
112+
body: request.body
113+
};
114+
115+
// HTTP-redirect fetch step 9
116+
if (res.statusCode !== 303 && request.body && getTotalBytes(request) === null) {
117+
reject(new FetchError('Cannot follow redirect with body being a readable stream', 'unsupported-redirect'))
118+
}
119+
120+
// HTTP-redirect fetch step 11
121+
if (res.statusCode === 303 || ((res.statusCode === 301 || res.statusCode === 302) && request.method === 'POST')) {
122+
requestOpts.method = 'GET';
123+
requestOpts.body = undefined;
124+
requestOpts.headers.delete('content-length');
125+
}
126+
127+
// HTTP-redirect fetch step 15
128+
resolve(fetch(new Request(locationURL, requestOpts)));
129+
return;
103130
}
104-
105-
resolve(fetch(new Request(resolve_url(request.url, res.headers.location), requestOpts)));
106-
return;
107-
}
108-
109-
const headers = createHeadersLenient(res.headers);
110-
// normalize location header for manual redirect mode
111-
if (request.redirect === 'manual' && headers.has('Location')) {
112-
headers.set('Location', resolve_url(request.url, headers.get('Location')));
113131
}
114132

115133
// prepare response

test/server.js

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -250,9 +250,8 @@ export default class TestServer {
250250
res.end();
251251
}
252252

253-
if (p === '/error/redirect') {
253+
if (p === '/redirect/no-location') {
254254
res.statusCode = 301;
255-
//res.setHeader('Location', '/inspect');
256255
res.end();
257256
}
258257

test/test.js

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,22 @@ describe('node-fetch', () => {
274274
});
275275
});
276276

277+
it('should follow PATCH request redirect code 301 with PATCH', function() {
278+
url = `${base}redirect/301`;
279+
opts = {
280+
method: 'PATCH'
281+
, body: 'a=1'
282+
};
283+
return fetch(url, opts).then(res => {
284+
expect(res.url).to.equal(`${base}inspect`);
285+
expect(res.status).to.equal(200);
286+
return res.json().then(res => {
287+
expect(res.method).to.equal('PATCH');
288+
expect(res.body).to.equal('a=1');
289+
});
290+
});
291+
});
292+
277293
it('should follow POST request redirect code 302 with GET', function() {
278294
url = `${base}redirect/302`;
279295
opts = {
@@ -290,6 +306,22 @@ describe('node-fetch', () => {
290306
});
291307
});
292308

309+
it('should follow PATCH request redirect code 302 with PATCH', function() {
310+
url = `${base}redirect/302`;
311+
opts = {
312+
method: 'PATCH'
313+
, body: 'a=1'
314+
};
315+
return fetch(url, opts).then(res => {
316+
expect(res.url).to.equal(`${base}inspect`);
317+
expect(res.status).to.equal(200);
318+
return res.json().then(res => {
319+
expect(res.method).to.equal('PATCH');
320+
expect(res.body).to.equal('a=1');
321+
});
322+
});
323+
});
324+
293325
it('should follow redirect code 303 with GET', function() {
294326
url = `${base}redirect/303`;
295327
opts = {
@@ -306,6 +338,22 @@ describe('node-fetch', () => {
306338
});
307339
});
308340

341+
it('should follow PATCH request redirect code 307 with PATCH', function() {
342+
url = `${base}redirect/307`;
343+
opts = {
344+
method: 'PATCH'
345+
, body: 'a=1'
346+
};
347+
return fetch(url, opts).then(res => {
348+
expect(res.url).to.equal(`${base}inspect`);
349+
expect(res.status).to.equal(200);
350+
return res.json().then(result => {
351+
expect(result.method).to.equal('PATCH');
352+
expect(result.body).to.equal('a=1');
353+
});
354+
});
355+
});
356+
309357
it('should obey maximum redirect, reject case', function() {
310358
url = `${base}redirect/chain`;
311359
opts = {
@@ -384,15 +432,17 @@ describe('node-fetch', () => {
384432
});
385433
});
386434

387-
it('should reject broken redirect', function() {
388-
url = `${base}error/redirect`;
389-
return expect(fetch(url)).to.eventually.be.rejected
390-
.and.be.an.instanceOf(FetchError)
391-
.and.have.property('type', 'invalid-redirect');
435+
it('should treat broken redirect as ordinary response (follow)', function() {
436+
url = `${base}redirect/no-location`;
437+
return fetch(url, opts).then(res => {
438+
expect(res.url).to.equal(url);
439+
expect(res.status).to.equal(301);
440+
expect(res.headers.get('location')).to.be.null;
441+
});
392442
});
393443

394-
it('should not reject broken redirect under manual redirect', function() {
395-
url = `${base}error/redirect`;
444+
it('should treat broken redirect as ordinary response (manual)', function() {
445+
url = `${base}redirect/no-location`;
396446
opts = {
397447
redirect: 'manual'
398448
};

0 commit comments

Comments
 (0)