UNPKG

@zimic/interceptor

Version:

Next-gen TypeScript-first HTTP intercepting and mocking

417 lines (333 loc) 13.2 kB
import { normalizeNodeRequest, sendNodeResponse } from '@whatwg-node/server'; import { HttpRequest, HttpMethod } from '@zimic/http'; import createRegExpFromURL from '@zimic/utils/url/createRegExpFromURL'; import excludeURLParams from '@zimic/utils/url/excludeURLParams'; 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 { getHttpServerPort, startHttpServer, stopHttpServer } from '@/utils/http'; 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; url: { base: string; fullRegex: RegExp; }; socket: Socket; } class InterceptorServer implements PublicInterceptorServer { private httpServer?: HttpServer; private webSocketServer?: WebSocketServer<InterceptorServerWebSocketSchema>; _hostname: string; _port: number | undefined; logUnhandledRequests: boolean; tokensDirectory?: string; private httpHandlerGroups: { [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() { await startHttpServer(this.httpServerOrThrow, { hostname: this.hostname, port: this.port, }); this.port = getHttpServerPort(this.httpServerOrThrow); this.httpServerOrThrow.on('request', this.handleHttpRequest); } private startWebSocketServer() { this.webSocketServerOrThrow.start(); this.webSocketServerOrThrow.onEvent('interceptors/workers/commit', this.commitWorker); this.webSocketServerOrThrow.onEvent('interceptors/workers/reset', this.resetWorker); } private commitWorker = ( message: WebSocketEventMessage<InterceptorServerWebSocketSchema, 'interceptors/workers/commit'>, socket: Socket, ) => { const commit = message.data; this.registerHttpHandler(commit, socket); this.registerWorkerSocketIfUnknown(socket); return {}; }; private resetWorker = ( message: WebSocketEventMessage<InterceptorServerWebSocketSchema, 'interceptors/workers/reset'>, socket: Socket, ) => { this.removeHttpHandlersBySocket(socket); const handlersToResetTo = message.data; const isWorkerNoLongerCommitted = handlersToResetTo === undefined; if (isWorkerNoLongerCommitted) { // When a worker is no longer committed, we should abort all requests that were using it. // This ensures that we only wait for responses from committed worker sockets. this.webSocketServerOrThrow.abortSocketMessages([socket]); } else { for (const handler of handlersToResetTo) { this.registerHttpHandler(handler, socket); } } this.registerWorkerSocketIfUnknown(socket); return {}; }; private registerHttpHandler({ id, url, method }: HttpHandlerCommit, socket: Socket) { const handlerGroups = this.httpHandlerGroups[method]; const fullURL = new URL(url.full); excludeURLParams(fullURL); handlerGroups.push({ id, url: { base: url.base, fullRegex: createRegExpFromURL(fullURL.toString()), }, 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.httpHandlerGroups)) { 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.offEvent('interceptors/workers/commit', this.commitWorker); this.webSocketServerOrThrow.offEvent('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.httpHandlerGroups[request.method as HttpMethod]; const requestURL = excludeURLParams(new URL(request.url)).toString(); let matchedSomeInterceptor = false; for (let index = methodHandlers.length - 1; index >= 0; index--) { const handler = methodHandlers[index]; const matchesHandlerURL = handler.url.fullRegex.test(requestURL); if (!matchesHandlerURL) { 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 * This try..catch is for the case when the remote interceptor web socket client is closed before responding. * Since simulating this scenario is difficult, we are ignoring this branch fow now. */ 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.httpHandlerGroups[request.method as HttpMethod]; const handler = methodHandlers.findLast((handler) => { return request.url.startsWith(handler.url.base); }); return handler; } } export default InterceptorServer;