Skip to content

Commit

Permalink
feat(fetch): support following redirect responses (#627)
Browse files Browse the repository at this point in the history
  • Loading branch information
kettanaito authored Sep 8, 2024
1 parent c4b68ba commit 7410d45
Show file tree
Hide file tree
Showing 6 changed files with 391 additions and 7 deletions.
36 changes: 30 additions & 6 deletions src/interceptors/fetch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import { emitAsync } from '../../utils/emitAsync'
import { handleRequest } from '../../utils/handleRequest'
import { canParseUrl } from '../../utils/canParseUrl'
import { createRequestId } from '../../createRequestId'
import { RESPONSE_STATUS_CODES_WITH_REDIRECT } from '../../utils/responseUtils'
import { createNetworkError } from './utils/createNetworkError'
import { followFetchRedirect } from './utils/followRedirect'

export class FetchInterceptor extends Interceptor<HttpRequestEventMap> {
static symbol = Symbol('fetch')
Expand Down Expand Up @@ -68,6 +71,33 @@ export class FetchInterceptor extends Interceptor<HttpRequestEventMap> {
response,
})

/**
* Undici's handling of following redirect responses.
* Treat the "manual" redirect mode as a regular mocked response.
* This way, the client can manually follow the redirect it receives.
* @see https://github.com/nodejs/undici/blob/a6dac3149c505b58d2e6d068b97f4dc993da55f0/lib/web/fetch/index.js#L1173
*/
if (RESPONSE_STATUS_CODES_WITH_REDIRECT.has(response.status)) {
// Reject the request promise if its `redirect` is set to `error`
// and it receives a mocked redirect response.
if (request.redirect === 'error') {
responsePromise.reject(createNetworkError('unexpected redirect'))
return
}

if (request.redirect === 'follow') {
followFetchRedirect(request, response).then(
(response) => {
responsePromise.resolve(response)
},
(reason) => {
responsePromise.reject(reason)
}
)
return
}
}

if (this.emitter.listenerCount('response') > 0) {
this.logger.info('emitting the "response" event...')

Expand Down Expand Up @@ -154,9 +184,3 @@ export class FetchInterceptor extends Interceptor<HttpRequestEventMap> {
})
}
}

function createNetworkError(cause: unknown) {
return Object.assign(new TypeError('Failed to fetch'), {
cause,
})
}
5 changes: 5 additions & 0 deletions src/interceptors/fetch/utils/createNetworkError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export function createNetworkError(cause?: unknown) {
return Object.assign(new TypeError('Failed to fetch'), {
cause,
})
}
107 changes: 107 additions & 0 deletions src/interceptors/fetch/utils/followRedirect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { createNetworkError } from './createNetworkError'

const REQUEST_BODY_HEADERS = [
'content-encoding',
'content-language',
'content-location',
'content-type',
'content-length',
]

const kRedirectCount = Symbol('kRedirectCount')

/**
* @see https://github.com/nodejs/undici/blob/a6dac3149c505b58d2e6d068b97f4dc993da55f0/lib/web/fetch/index.js#L1210
*/
export async function followFetchRedirect(
request: Request,
response: Response
): Promise<Response> {
if (response.status !== 303 && request.body != null) {
return Promise.reject(createNetworkError())
}

const requestUrl = new URL(request.url)

let locationUrl: URL
try {
locationUrl = new URL(response.headers.get('location')!)
} catch (error) {
return Promise.reject(createNetworkError(error))
}

if (
!(locationUrl.protocol === 'http:' || locationUrl.protocol === 'https:')
) {
return Promise.reject(
createNetworkError('URL scheme must be a HTTP(S) scheme')
)
}

if (Reflect.get(request, kRedirectCount) > 20) {
return Promise.reject(createNetworkError('redirect count exceeded'))
}

Object.defineProperty(request, kRedirectCount, {
value: (Reflect.get(request, kRedirectCount) || 0) + 1,
})

if (
request.mode === 'cors' &&
(locationUrl.username || locationUrl.password) &&
!sameOrigin(requestUrl, locationUrl)
) {
return Promise.reject(
createNetworkError('cross origin not allowed for request mode "cors"')
)
}

const requestInit: RequestInit = {}

if (
([301, 302].includes(response.status) && request.method === 'POST') ||
(response.status === 303 && !['HEAD', 'GET'].includes(request.method))
) {
requestInit.method = 'GET'
requestInit.body = null

REQUEST_BODY_HEADERS.forEach((headerName) => {
request.headers.delete(headerName)
})
}

if (!sameOrigin(requestUrl, locationUrl)) {
request.headers.delete('authorization')
request.headers.delete('proxy-authorization')
request.headers.delete('cookie')
request.headers.delete('host')
}

/**
* @note Undici "safely" extracts the request body.
* I suspect we cannot dispatch this request again
* since its body has been read and the stream is locked.
*/

requestInit.headers = request.headers
return fetch(new Request(locationUrl, requestInit))
}

/**
* @see https://github.com/nodejs/undici/blob/a6dac3149c505b58d2e6d068b97f4dc993da55f0/lib/web/fetch/util.js#L761
*/
function sameOrigin(left: URL, right: URL): boolean {
if (left.origin === right.origin && left.origin === 'null') {
return true
}

if (
left.protocol === right.protocol &&
left.hostname === right.hostname &&
left.port === right.port
) {
return true
}

return false
}
4 changes: 4 additions & 0 deletions src/utils/responseUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ export const RESPONSE_STATUS_CODES_WITHOUT_BODY = new Set([
101, 103, 204, 205, 304,
])

export const RESPONSE_STATUS_CODES_WITH_REDIRECT = new Set([
301, 302, 303, 307, 308,
])

/**
* Returns a boolean indicating whether the given response status
* code represents a response that cannot have a body.
Expand Down
Loading

0 comments on commit 7410d45

Please sign in to comment.