Spaces:
Running
Running
import type { Emitter } from 'strict-event-emitter' | |
import { DeferredPromise } from '@open-draft/deferred-promise' | |
import { until } from '@open-draft/until' | |
import type { HttpRequestEventMap } from '../glossary' | |
import { emitAsync } from './emitAsync' | |
import { kResponsePromise, RequestController } from '../RequestController' | |
import { | |
createServerErrorResponse, | |
isResponseError, | |
ResponseError, | |
} from './responseUtils' | |
import { InterceptorError } from '../InterceptorError' | |
import { isNodeLikeError } from './isNodeLikeError' | |
interface HandleRequestOptions { | |
requestId: string | |
request: Request | |
emitter: Emitter<HttpRequestEventMap> | |
controller: RequestController | |
/** | |
* Called when the request has been handled | |
* with the given `Response` instance. | |
*/ | |
onResponse: (response: Response) => void | Promise<void> | |
/** | |
* Called when the request has been handled | |
* with the given `Response.error()` instance. | |
*/ | |
onRequestError: (response: ResponseError) => void | |
/** | |
* Called when an unhandled error happens during the | |
* request handling. This is never a thrown error/response. | |
*/ | |
onError: (error: unknown) => void | |
} | |
/** | |
* @returns {Promise<boolean>} Indicates whether the request has been handled. | |
*/ | |
export async function handleRequest( | |
options: HandleRequestOptions | |
): Promise<boolean> { | |
const handleResponse = async (response: Response | Error) => { | |
if (response instanceof Error) { | |
options.onError(response) | |
} | |
// Handle "Response.error()" instances. | |
else if (isResponseError(response)) { | |
options.onRequestError(response) | |
} else { | |
await options.onResponse(response) | |
} | |
return true | |
} | |
const handleResponseError = async (error: unknown): Promise<boolean> => { | |
// Forward the special interceptor error instances | |
// to the developer. These must not be handled in any way. | |
if (error instanceof InterceptorError) { | |
throw result.error | |
} | |
// Support mocking Node.js-like errors. | |
if (isNodeLikeError(error)) { | |
options.onError(error) | |
return true | |
} | |
// Handle thrown responses. | |
if (error instanceof Response) { | |
return await handleResponse(error) | |
} | |
return false | |
} | |
// Add the last "request" listener to check if the request | |
// has been handled in any way. If it hasn't, resolve the | |
// response promise with undefined. | |
options.emitter.once('request', ({ requestId: pendingRequestId }) => { | |
if (pendingRequestId !== options.requestId) { | |
return | |
} | |
if (options.controller[kResponsePromise].state === 'pending') { | |
options.controller[kResponsePromise].resolve(undefined) | |
} | |
}) | |
const requestAbortPromise = new DeferredPromise<void, unknown>() | |
/** | |
* @note `signal` is not always defined in React Native. | |
*/ | |
if (options.request.signal) { | |
if (options.request.signal.aborted) { | |
requestAbortPromise.reject(options.request.signal.reason) | |
} else { | |
options.request.signal.addEventListener( | |
'abort', | |
() => { | |
requestAbortPromise.reject(options.request.signal.reason) | |
}, | |
{ once: true } | |
) | |
} | |
} | |
const result = await until(async () => { | |
// Emit the "request" event and wait until all the listeners | |
// for that event are finished (e.g. async listeners awaited). | |
// By the end of this promise, the developer cannot affect the | |
// request anymore. | |
const requestListtenersPromise = emitAsync(options.emitter, 'request', { | |
requestId: options.requestId, | |
request: options.request, | |
controller: options.controller, | |
}) | |
await Promise.race([ | |
// Short-circuit the request handling promise if the request gets aborted. | |
requestAbortPromise, | |
requestListtenersPromise, | |
options.controller[kResponsePromise], | |
]) | |
// The response promise will settle immediately once | |
// the developer calls either "respondWith" or "errorWith". | |
const mockedResponse = await options.controller[kResponsePromise] | |
return mockedResponse | |
}) | |
// Handle the request being aborted while waiting for the request listeners. | |
if (requestAbortPromise.state === 'rejected') { | |
options.onError(requestAbortPromise.rejectionReason) | |
return true | |
} | |
if (result.error) { | |
// Handle the error during the request listener execution. | |
// These can be thrown responses or request errors. | |
if (await handleResponseError(result.error)) { | |
return true | |
} | |
// If the developer has added "unhandledException" listeners, | |
// allow them to handle the error. They can translate it to a | |
// mocked response, network error, or forward it as-is. | |
if (options.emitter.listenerCount('unhandledException') > 0) { | |
// Create a new request controller just for the unhandled exception case. | |
// This is needed because the original controller might have been already | |
// interacted with (e.g. "respondWith" or "errorWith" called on it). | |
const unhandledExceptionController = new RequestController( | |
options.request | |
) | |
await emitAsync(options.emitter, 'unhandledException', { | |
error: result.error, | |
request: options.request, | |
requestId: options.requestId, | |
controller: unhandledExceptionController, | |
}).then(() => { | |
// If all the "unhandledException" listeners have finished | |
// but have not handled the response in any way, preemptively | |
// resolve the pending response promise from the new controller. | |
// This prevents it from hanging forever. | |
if ( | |
unhandledExceptionController[kResponsePromise].state === 'pending' | |
) { | |
unhandledExceptionController[kResponsePromise].resolve(undefined) | |
} | |
}) | |
const nextResult = await until( | |
() => unhandledExceptionController[kResponsePromise] | |
) | |
/** | |
* @note Handle the result of the unhandled controller | |
* in the same way as the original request controller. | |
* The exception here is that thrown errors within the | |
* "unhandledException" event do NOT result in another | |
* emit of the same event. They are forwarded as-is. | |
*/ | |
if (nextResult.error) { | |
return handleResponseError(nextResult.error) | |
} | |
if (nextResult.data) { | |
return handleResponse(nextResult.data) | |
} | |
} | |
// Otherwise, coerce unhandled exceptions to a 500 Internal Server Error response. | |
options.onResponse(createServerErrorResponse(result.error)) | |
return true | |
} | |
/** | |
* Handle a mocked Response instance. | |
* @note That this can also be an Error in case | |
* the developer called "errorWith". This differentiates | |
* unhandled exceptions from intended errors. | |
*/ | |
if (result.data) { | |
return handleResponse(result.data) | |
} | |
// In all other cases, consider the request unhandled. | |
// The interceptor must perform it as-is. | |
return false | |
} | |