import { HttpHeadersInit, HttpHeaders, HttpBody, HttpResponse, HttpMethod, HttpMethodSchema, HttpSchema, HttpStatusCode, InferPathParams, parseHttpBody, HttpSearchParams, HttpRequest, } from '@zimic/http'; import { isDefined } from '@zimic/utils/data'; import { Default, PossiblePromise } from '@zimic/utils/types'; import color from 'picocolors'; import { removeArrayElement } from '@/utils/arrays'; import { isClientSide } from '@/utils/environment'; import { methodCanHaveResponseBody } from '@/utils/http'; import { formatValueToLog, logger } from '@/utils/logging'; import HttpInterceptorImplementation, { AnyHttpInterceptorImplementation, } from '../interceptor/HttpInterceptorImplementation'; import { HttpInterceptorPlatform, HttpInterceptorType, UnhandledRequestStrategy } from '../interceptor/types/options'; import { UnhandledHttpInterceptorRequestPath, UnhandledHttpInterceptorRequestMethodSchema, } from '../interceptor/types/requests'; import { HTTP_INTERCEPTOR_REQUEST_HIDDEN_PROPERTIES, HTTP_INTERCEPTOR_RESPONSE_HIDDEN_PROPERTIES, HttpInterceptorRequest, HttpInterceptorResponse, } from '../requestHandler/types/requests'; import { DEFAULT_UNHANDLED_REQUEST_STRATEGY } from './constants'; import { HttpResponseFactory } from './types/http'; import { HttpInterceptorWorkerType } from './types/options'; const RESPONSE_ACTION_SYMBOL = Symbol.for('HttpResponse.action'); abstract class HttpInterceptorWorker { abstract get type(): HttpInterceptorWorkerType; platform: HttpInterceptorPlatform | null = null; isRunning = false; private startingPromise?: Promise; private stoppingPromise?: Promise; private runningInterceptors: AnyHttpInterceptorImplementation[] = []; abstract start(): Promise; protected async sharedStart(internalStart: () => Promise) { if (this.isRunning) { return; } if (this.startingPromise) { return this.startingPromise; } try { this.startingPromise = internalStart(); await this.startingPromise; this.startingPromise = undefined; } catch (error) { // In server side (e.g. Node.js), we need to manually log the error because this will be treated as an unhandled // promise rejection. If we don't log it, the output won't contain details about the error. In the browser, // uncaught promise rejections are automatically logged. if (!isClientSide()) { console.error(error); } await this.stop(); throw error; } } abstract stop(): Promise; protected async sharedStop(internalStop: () => PossiblePromise) { if (!this.isRunning) { return; } if (this.stoppingPromise) { return this.stoppingPromise; } const stoppingResult = internalStop(); /* istanbul ignore next -- @preserve * This if statement only runs if concurrent calls to stop() are made, which is an edge case that is hard to * reliably reproduce in tests. */ if (stoppingResult instanceof Promise) { this.stoppingPromise = stoppingResult; await this.stoppingPromise; } this.stoppingPromise = undefined; } abstract use( interceptor: HttpInterceptorImplementation, method: HttpMethod, path: string, createResponse: HttpResponseFactory, ): PossiblePromise; protected async logUnhandledRequestIfNecessary( request: Request, strategy: UnhandledRequestStrategy.Declaration | null, ) { if (strategy?.log) { await HttpInterceptorWorker.logUnhandledRequestWarning(request, strategy.action); return { wasLogged: true }; } return { wasLogged: false }; } protected async getUnhandledRequestStrategy(request: Request, interceptorType: HttpInterceptorType) { const candidates = await this.getUnhandledRequestStrategyCandidates(request, interceptorType); const strategy = this.reduceUnhandledRequestStrategyCandidates(candidates); return strategy; } private reduceUnhandledRequestStrategyCandidates(candidateStrategies: UnhandledRequestStrategy.Declaration[]) { if (candidateStrategies.length === 0) { return null; } // Prefer strategies from first to last, overriding undefined values with the next candidate. return candidateStrategies.reduce( (accumulatedStrategy, candidateStrategy): UnhandledRequestStrategy.Declaration => ({ action: accumulatedStrategy.action, log: accumulatedStrategy.log ?? candidateStrategy.log, }), ); } private async getUnhandledRequestStrategyCandidates( request: Request, interceptorType: HttpInterceptorType, ): Promise { const globalDefaultStrategy = DEFAULT_UNHANDLED_REQUEST_STRATEGY[interceptorType]; try { const interceptor = this.findInterceptorByRequestBaseURL(request); if (!interceptor) { return []; } const requestClone = request.clone(); const interceptorStrategy = await this.getInterceptorUnhandledRequestStrategy(requestClone, interceptor); return [interceptorStrategy, globalDefaultStrategy].filter(isDefined); } catch (error) { console.error(error); return [globalDefaultStrategy]; } } registerRunningInterceptor(interceptor: AnyHttpInterceptorImplementation) { this.runningInterceptors.push(interceptor); } unregisterRunningInterceptor(interceptor: AnyHttpInterceptorImplementation) { removeArrayElement(this.runningInterceptors, interceptor); } private findInterceptorByRequestBaseURL(request: Request) { const interceptor = this.runningInterceptors.findLast((interceptor) => { return request.url.startsWith(interceptor.baseURLAsString); }); return interceptor; } private async getInterceptorUnhandledRequestStrategy( request: Request, interceptor: AnyHttpInterceptorImplementation, ) { if (typeof interceptor.onUnhandledRequest === 'function') { const parsedRequest = await HttpInterceptorWorker.parseRawUnhandledRequest(request); return interceptor.onUnhandledRequest(parsedRequest); } return interceptor.onUnhandledRequest; } abstract clearHandlers(options?: { interceptor?: HttpInterceptorImplementation; }): PossiblePromise; abstract get interceptorsWithHandlers(): AnyHttpInterceptorImplementation[]; static setResponseAction(response: Response, action: UnhandledRequestStrategy.Action) { Object.defineProperty(response, RESPONSE_ACTION_SYMBOL, { value: action, enumerable: false, configurable: false, writable: false, }); } static getResponseAction(response: Response): UnhandledRequestStrategy.Action | undefined { if (!(RESPONSE_ACTION_SYMBOL in response)) { return undefined; } const action = response[RESPONSE_ACTION_SYMBOL]; /* istanbul ignore if -- @preserve * This is just a type guard to ensure the value is valid. In practice, this condition should never be true. */ if (action !== 'bypass' && action !== 'reject') { return undefined; } return action; } private createBypassedResponse() { const response = Response.redirect('about:blank', 302) as HttpResponse; HttpInterceptorWorker.setResponseAction(response, 'bypass'); return response; } static isBypassedResponse(response: Response) { return this.getResponseAction(response) === 'bypass'; } private createRejectedResponse() { const response = Response.error() as HttpResponse; HttpInterceptorWorker.setResponseAction(response, 'reject'); return response; } static isRejectedResponse(response: Response) { return this.getResponseAction(response) === 'reject'; } createResponseFromDeclaration( request: HttpRequest, declaration: | { status: number; headers?: HttpHeadersInit; body?: HttpBody } | { action: UnhandledRequestStrategy.Action }, ): PossiblePromise { if ('action' in declaration) { if (declaration.action === 'bypass') { return this.createBypassedResponse(); } else { return this.createRejectedResponse(); } } const headers = new HttpHeaders(declaration.headers); const canHaveBody = methodCanHaveResponseBody(request.method as HttpMethod) && declaration.status !== 204; if (!canHaveBody) { return new Response(null, { headers, status: declaration.status }) as HttpResponse; } if ( typeof declaration.body === 'string' || declaration.body === null || declaration.body === undefined || declaration.body instanceof FormData || declaration.body instanceof URLSearchParams || declaration.body instanceof Blob || declaration.body instanceof ArrayBuffer || declaration.body instanceof ReadableStream ) { return new Response(declaration.body ?? null, { headers, status: declaration.status }) as HttpResponse; } return Response.json(declaration.body, { headers, status: declaration.status }) as HttpResponse; } static async parseRawUnhandledRequest(request: Request) { return this.parseRawRequest( request, ); } static async parseRawRequest( originalRawRequest: Request, options?: { baseURL: string; pathRegex: RegExp }, ): Promise> { const rawRequest = originalRawRequest.clone(); const rawRequestClone = rawRequest.clone(); type BodySchema = Default['body']>; const parsedBody = await parseHttpBody(rawRequest).catch((error: unknown) => { logger.error('Failed to parse request body:', error); return null; }); type HeadersSchema = Default['headers']>; const headers = new HttpHeaders(rawRequest.headers); const pathParams = this.parseRawPathParams(rawRequest, options); const parsedURL = new URL(rawRequest.url); type SearchParamsSchema = Default['searchParams']>; const searchParams = new HttpSearchParams(parsedURL.searchParams); const parsedRequest = new Proxy(rawRequest as unknown as HttpInterceptorRequest, { has(target, property: keyof HttpInterceptorRequest) { if (HttpInterceptorWorker.isHiddenRequestProperty(property)) { return false; } return Reflect.has(target, property); }, get(target, property: keyof HttpInterceptorRequest) { if (HttpInterceptorWorker.isHiddenRequestProperty(property)) { return undefined; } return Reflect.get(target, property, target) as unknown; }, }); Object.defineProperty(parsedRequest, 'body', { value: parsedBody, enumerable: true, configurable: false, writable: false, }); Object.defineProperty(parsedRequest, 'headers', { value: headers, enumerable: true, configurable: false, writable: false, }); Object.defineProperty(parsedRequest, 'pathParams', { value: pathParams, enumerable: true, configurable: false, writable: false, }); Object.defineProperty(parsedRequest, 'searchParams', { value: searchParams, enumerable: true, configurable: false, writable: false, }); Object.defineProperty(parsedRequest, 'raw', { value: rawRequestClone, enumerable: true, configurable: false, writable: false, }); return parsedRequest; } private static isHiddenRequestProperty(property: string) { return HTTP_INTERCEPTOR_REQUEST_HIDDEN_PROPERTIES.has(property as never); } static async parseRawResponse< MethodSchema extends HttpMethodSchema, StatusCode extends HttpStatusCode = HttpStatusCode, >(originalRawResponse: Response): Promise> { const rawResponse = originalRawResponse.clone(); const rawResponseClone = rawResponse.clone(); type BodySchema = Default[StatusCode]>['body']>; const parsedBody = await parseHttpBody(rawResponse).catch((error: unknown) => { logger.error('Failed to parse response body:', error); return null; }); type HeadersSchema = Default[StatusCode]>['headers']>; const headers = new HttpHeaders(rawResponse.headers); const parsedRequest = new Proxy(rawResponse as unknown as HttpInterceptorResponse, { has(target, property: keyof HttpInterceptorResponse) { if (HttpInterceptorWorker.isHiddenResponseProperty(property)) { return false; } return Reflect.has(target, property); }, get(target, property: keyof HttpInterceptorResponse) { if (HttpInterceptorWorker.isHiddenResponseProperty(property)) { return undefined; } return Reflect.get(target, property, target) as unknown; }, }); Object.defineProperty(parsedRequest, 'body', { value: parsedBody, enumerable: true, configurable: false, writable: false, }); Object.defineProperty(parsedRequest, 'headers', { value: headers, enumerable: true, configurable: false, writable: false, }); Object.defineProperty(parsedRequest, 'raw', { value: rawResponseClone, enumerable: true, configurable: false, writable: false, }); return parsedRequest; } private static isHiddenResponseProperty(property: string) { return HTTP_INTERCEPTOR_RESPONSE_HIDDEN_PROPERTIES.has(property as never); } static parseRawPathParams( request: Request, options?: { baseURL: string; pathRegex: RegExp }, ): InferPathParams { const requestPath = request.url.replace(options?.baseURL ?? '', ''); const paramsMatch = options?.pathRegex.exec(requestPath); const params: Record = {}; for (const [paramName, paramValue] of Object.entries(paramsMatch?.groups ?? {})) { params[paramName] = typeof paramValue === 'string' ? decodeURIComponent(paramValue) : undefined; } return params as InferPathParams; } static async logUnhandledRequestWarning(rawRequest: Request, action: UnhandledRequestStrategy.Action) { const request = await this.parseRawRequest(rawRequest); const [formattedHeaders, formattedSearchParams, formattedBody] = await Promise.all([ formatValueToLog(request.headers.toObject()), formatValueToLog(request.searchParams.toObject()), formatValueToLog(request.body), ]); logger[action === 'bypass' ? 'warn' : 'error']( `${action === 'bypass' ? 'Warning:' : 'Error:'} Request was not handled and was ` + `${action === 'bypass' ? color.yellow('bypassed') : color.red('rejected')}.\n\n `, `${request.method} ${request.url}`, '\n Headers:', formattedHeaders, '\n Search params:', formattedSearchParams, '\n Body:', formattedBody, '\n\nLearn more: https://zimic.dev/docs/interceptor/guides/http/unhandled-requests', ); } } export default HttpInterceptorWorker;