UNPKG

@zimic/interceptor

Version:

Next-gen TypeScript-first HTTP intercepting and mocking

438 lines (351 loc) 14.6 kB
import { normalizeNodeRequest, sendNodeResponse } from '@whatwg-node/server'; import { HttpRequest, HttpMethod } from '@zimic/http'; import { startHttpServer, stopHttpServer, getHttpServerPort } from '@zimic/utils/server/lifecycle'; import createRegexFromPath from '@zimic/utils/url/createRegexFromPath'; import excludeNonPathParams from '@zimic/utils/url/excludeNonPathParams'; import { createServer, Server as HttpServer, IncomingMessage, ServerResponse } from 'http'; import type { WebSocket as Socket } from 'isomorphic-ws'; import HttpInterceptorWorker from '@/http/interceptorWorker/HttpInterceptorWorker'; import { removeArrayIndex } from '@/utils/arrays'; import { deserializeResponse, SerializedHttpRequest, serializeRequest } from '@/utils/fetch'; import { WebSocketMessageAbortError } from '@/utils/webSocket'; import { WebSocketEventMessage } from '@/webSocket/types'; import WebSocketServer, { WebSocketServerAuthenticate } from '@/webSocket/WebSocketServer'; import { DEFAULT_ACCESS_CONTROL_HEADERS, DEFAULT_PREFLIGHT_STATUS_CODE, DEFAULT_LOG_UNHANDLED_REQUESTS, DEFAULT_HOSTNAME, } from './constants'; import NotRunningInterceptorServerError from './errors/NotRunningInterceptorServerError'; import RunningInterceptorServerError from './errors/RunningInterceptorServerError'; import { InterceptorServerOptions } from './types/options'; import { InterceptorServer as PublicInterceptorServer } from './types/public'; import { HttpHandlerCommit, InterceptorServerWebSocketSchema } from './types/schema'; import { validateInterceptorToken } from './utils/auth'; import { getFetchAPI } from './utils/fetch'; interface HttpHandler { id: string; baseURL: string; pathRegex: RegExp; socket: Socket; } class InterceptorServer implements PublicInterceptorServer { private httpServer?: HttpServer; private webSocketServer?: WebSocketServer<InterceptorServerWebSocketSchema>; _hostname: string; _port: number | undefined; logUnhandledRequests: boolean; tokensDirectory?: string; private httpHandlersByMethod: { [Method in HttpMethod]: HttpHandler[]; } = { GET: [], POST: [], PATCH: [], PUT: [], DELETE: [], HEAD: [], OPTIONS: [], }; private knownWorkerSockets = new Set<Socket>(); constructor(options: InterceptorServerOptions) { this._hostname = options.hostname ?? DEFAULT_HOSTNAME; this._port = options.port; this.logUnhandledRequests = options.logUnhandledRequests ?? DEFAULT_LOG_UNHANDLED_REQUESTS; this.tokensDirectory = options.tokensDirectory; } get hostname() { return this._hostname; } set hostname(newHostname: string) { if (this.isRunning) { throw new RunningInterceptorServerError('Did you forget to stop it before changing the hostname?'); } this._hostname = newHostname; } get port() { return this._port; } set port(newPort: number | undefined) { if (this.isRunning) { throw new RunningInterceptorServerError('Did you forget to stop it before changing the port?'); } this._port = newPort; } get isRunning() { return !!this.httpServer?.listening && !!this.webSocketServer?.isRunning; } private get httpServerOrThrow(): HttpServer { /* istanbul ignore if -- @preserve * The HTTP server is initialized before using this method in normal conditions. */ if (!this.httpServer) { throw new NotRunningInterceptorServerError(); } return this.httpServer; } private get webSocketServerOrThrow(): WebSocketServer<InterceptorServerWebSocketSchema> { /* istanbul ignore if -- @preserve * The web socket server is initialized before using this method in normal conditions. */ if (!this.webSocketServer) { throw new NotRunningInterceptorServerError(); } return this.webSocketServer; } async start() { if (this.isRunning) { return; } this.httpServer = createServer({ keepAlive: true, joinDuplicateHeaders: true, }); await this.startHttpServer(); this.webSocketServer = new WebSocketServer({ httpServer: this.httpServer, authenticate: this.authenticateWebSocketConnection, }); this.startWebSocketServer(); } private authenticateWebSocketConnection: WebSocketServerAuthenticate = async (_socket, request) => { if (!this.tokensDirectory) { return { isValid: true }; } const tokenValue = this.getWebSocketRequestTokenValue(request); if (!tokenValue) { return { isValid: false, message: 'An interceptor token is required, but none was provided.' }; } try { await validateInterceptorToken(tokenValue, { tokensDirectory: this.tokensDirectory }); return { isValid: true }; } catch (error) { console.error(error); return { isValid: false, message: 'The interceptor token is not valid.' }; } }; private getWebSocketRequestTokenValue(request: IncomingMessage) { const protocols = request.headers['sec-websocket-protocol'] ?? ''; const parametersAsString = decodeURIComponent(protocols).split(', '); for (const parameterAsString of parametersAsString) { const tokenValueMatch = /^token=(?<tokenValue>.+?)$/.exec(parameterAsString); const tokenValue = tokenValueMatch?.groups?.tokenValue; if (tokenValue) { return tokenValue; } } return undefined; } private async startHttpServer() { this.httpServerOrThrow.on('request', this.handleHttpRequest); await startHttpServer(this.httpServerOrThrow, { hostname: this.hostname, port: this.port, }); this.port = getHttpServerPort(this.httpServerOrThrow); } private startWebSocketServer() { this.webSocketServerOrThrow.onChannel('event', 'interceptors/workers/commit', this.commitWorker); this.webSocketServerOrThrow.onChannel('event', 'interceptors/workers/reset', this.resetWorker); this.webSocketServerOrThrow.start(); } private commitWorker = ( message: WebSocketEventMessage<InterceptorServerWebSocketSchema, 'interceptors/workers/commit'>, socket: Socket, ) => { const commit = message.data; this.registerHttpHandler(commit, socket); this.registerWorkerSocketIfUnknown(socket); return {}; }; private resetWorker = ( { data: handlersToRecommit }: WebSocketEventMessage<InterceptorServerWebSocketSchema, 'interceptors/workers/reset'>, socket: Socket, ) => { this.registerWorkerSocketIfUnknown(socket); this.webSocketServerOrThrow.emitSocket('abortRequests', socket, { shouldAbortRequest: (request) => { const isResponseCreationRequest = this.webSocketServerOrThrow.isChannelEvent( request, 'interceptors/responses/create', ); /* istanbul ignore if -- @preserve * While resetting a worker, there could be other types of requests in progress. These are not guaranteed to * exist and are not related to handler resets, so we let them continue. */ if (!isResponseCreationRequest) { return false; } // TODO: create a test with two interceptors, one for each path,, and reset only one of them. const isHandlerStillCommitted = handlersToRecommit.some( /* istanbul ignore next -- @preserve * Ensuring this function is called in tests is difficult because it requires clearing or stopping a worker * at the exact moment a request is being handled, in a scenario when there are other handlers still * committed. */ (handler) => request.data.handlerId === handler.id, ); return !isHandlerStillCommitted; }, }); this.removeHttpHandlersBySocket(socket); for (const handler of handlersToRecommit) { this.registerHttpHandler(handler, socket); } return {}; }; private registerHttpHandler({ id, baseURL, method, path }: HttpHandlerCommit, socket: Socket) { const handlerGroups = this.httpHandlersByMethod[method]; handlerGroups.push({ id, baseURL, pathRegex: createRegexFromPath(path), socket, }); } private registerWorkerSocketIfUnknown(socket: Socket) { if (this.knownWorkerSockets.has(socket)) { return; } socket.addEventListener('close', () => { this.removeHttpHandlersBySocket(socket); this.knownWorkerSockets.delete(socket); }); this.knownWorkerSockets.add(socket); } private removeHttpHandlersBySocket(socket: Socket) { for (const handlerGroups of Object.values(this.httpHandlersByMethod)) { const socketIndex = handlerGroups.findIndex((handlerGroup) => handlerGroup.socket === socket); removeArrayIndex(handlerGroups, socketIndex); } } async stop() { if (!this.isRunning) { return; } await this.stopWebSocketServer(); await this.stopHttpServer(); } private async stopHttpServer() { await stopHttpServer(this.httpServerOrThrow); this.httpServerOrThrow.removeAllListeners(); this.httpServer = undefined; } private async stopWebSocketServer() { this.webSocketServerOrThrow.offChannel('event', 'interceptors/workers/commit', this.commitWorker); this.webSocketServerOrThrow.offChannel('event', 'interceptors/workers/reset', this.resetWorker); await this.webSocketServerOrThrow.stop(); this.webSocketServer = undefined; } private handleHttpRequest = async (nodeRequest: IncomingMessage, nodeResponse: ServerResponse) => { const request = normalizeNodeRequest(nodeRequest, await getFetchAPI()); const serializedRequest = await serializeRequest(request); try { const { response, matchedSomeInterceptor } = await this.createResponseForRequest(serializedRequest); if (response) { this.setDefaultAccessControlHeaders(response, ['access-control-allow-origin', 'access-control-expose-headers']); await sendNodeResponse(response, nodeResponse, nodeRequest, true); return; } const isUnhandledPreflightResponse = request.method === 'OPTIONS'; if (isUnhandledPreflightResponse) { const defaultPreflightResponse = new Response(null, { status: DEFAULT_PREFLIGHT_STATUS_CODE }); this.setDefaultAccessControlHeaders(defaultPreflightResponse); await sendNodeResponse(defaultPreflightResponse, nodeResponse, nodeRequest, true); } const shouldWarnUnhandledRequest = !isUnhandledPreflightResponse && !matchedSomeInterceptor; if (shouldWarnUnhandledRequest) { await this.logUnhandledRequestIfNecessary(request, serializedRequest); } nodeResponse.destroy(); } catch (error) { const isMessageAbortError = error instanceof WebSocketMessageAbortError; if (!isMessageAbortError) { console.error(error); await this.logUnhandledRequestIfNecessary(request, serializedRequest); } nodeResponse.destroy(); } }; private async createResponseForRequest(request: SerializedHttpRequest) { const methodHandlers = this.httpHandlersByMethod[request.method as HttpMethod]; const requestURL = excludeNonPathParams(new URL(request.url)); const requestURLAsString = requestURL.href === `${requestURL.origin}/` ? requestURL.origin : requestURL.href; let matchedSomeInterceptor = false; for (let handlerIndex = methodHandlers.length - 1; handlerIndex >= 0; handlerIndex--) { const handler = methodHandlers[handlerIndex]; const matchesBaseURL = requestURLAsString.startsWith(handler.baseURL); if (!matchesBaseURL) { continue; } const requestPath = requestURLAsString.replace(handler.baseURL, ''); const matchesPath = handler.pathRegex.test(requestPath); if (!matchesPath) { continue; } matchedSomeInterceptor = true; const { response: serializedResponse } = await this.webSocketServerOrThrow.request( 'interceptors/responses/create', { handlerId: handler.id, request }, { sockets: [handler.socket] }, ); if (serializedResponse) { const response = deserializeResponse(serializedResponse); return { response, matchedSomeInterceptor }; } } return { response: null, matchedSomeInterceptor }; } private setDefaultAccessControlHeaders( response: Response, headersToSet = Object.keys(DEFAULT_ACCESS_CONTROL_HEADERS), ) { for (const key of headersToSet) { if (response.headers.has(key)) { continue; } const value = DEFAULT_ACCESS_CONTROL_HEADERS[key]; /* istanbul ignore else -- @preserve * This is always true during tests because we force max-age=0 to disable CORS caching. */ if (value) { response.headers.set(key, value); } } } private async logUnhandledRequestIfNecessary(request: HttpRequest, serializedRequest: SerializedHttpRequest) { const handler = this.findHttpHandlerByRequestBaseURL(request); if (handler) { try { const { wasLogged: wasRequestLoggedByRemoteInterceptor } = await this.webSocketServerOrThrow.request( 'interceptors/responses/unhandled', { request: serializedRequest }, { sockets: [handler.socket] }, ); if (wasRequestLoggedByRemoteInterceptor) { return; } } catch (error) { /* istanbul ignore next -- @preserve * * If the socket is closed before receiving a response, the message is aborted with an error. This can happen if * we send a request message and the interceptor worker closes the socket before sending a response. In this * case, we can safely ignore the error because we know that the worker is shutting down and won't handle * any more requests. * * Due to the rare nature of this edge case, we can't reliably reproduce it in tests. */ const isMessageAbortError = error instanceof WebSocketMessageAbortError; /* istanbul ignore next -- @preserve */ if (!isMessageAbortError) { throw error; } } } if (!this.logUnhandledRequests) { return; } await HttpInterceptorWorker.logUnhandledRequestWarning(request, 'reject'); } private findHttpHandlerByRequestBaseURL(request: HttpRequest) { const methodHandlers = this.httpHandlersByMethod[request.method as HttpMethod]; const handler = methodHandlers.findLast((handler) => request.url.startsWith(handler.baseURL)); return handler; } } export default InterceptorServer;