UNPKG

@zimic/interceptor

Version:

Next-gen TypeScript-first HTTP intercepting and mocking

227 lines (185 loc) 7.63 kB
import { HttpMethod, HttpSchema } from '@zimic/http'; import validatePathParams from '@zimic/utils/url/validatePathParams'; import { HttpHandlerCommit, InterceptorServerWebSocketSchema } from '@/server/types/schema'; import { importCrypto } from '@/utils/crypto'; import { isClientSide, isServerSide } from '@/utils/environment'; import { deserializeRequest, serializeResponse } from '@/utils/fetch'; import { WebSocketMessageAbortError } from '@/utils/webSocket'; import { WebSocketEventMessage } from '@/webSocket/types'; import WebSocketClient from '@/webSocket/WebSocketClient'; import NotRunningHttpInterceptorError from '../interceptor/errors/NotRunningHttpInterceptorError'; import UnknownHttpInterceptorPlatformError from '../interceptor/errors/UnknownHttpInterceptorPlatformError'; import HttpInterceptorClient, { AnyHttpInterceptorClient } from '../interceptor/HttpInterceptorClient'; import { HttpInterceptorPlatform } from '../interceptor/types/options'; import HttpInterceptorWorker from './HttpInterceptorWorker'; import { HttpResponseFactoryContext } from './types/http'; import { MSWHttpResponseFactory } from './types/msw'; import { RemoteHttpInterceptorWorkerOptions } from './types/options'; interface HttpHandler { id: string; baseURL: string; method: HttpMethod; path: string; interceptor: AnyHttpInterceptorClient; createResponse: (context: HttpResponseFactoryContext) => Promise<Response | null>; } class RemoteHttpInterceptorWorker extends HttpInterceptorWorker { private httpHandlers = new Map<HttpHandler['id'], HttpHandler>(); webSocketClient: WebSocketClient<InterceptorServerWebSocketSchema>; private auth?: RemoteHttpInterceptorWorkerOptions['auth']; constructor(options: RemoteHttpInterceptorWorkerOptions) { super(); this.webSocketClient = new WebSocketClient({ url: this.getWebSocketServerURL(options.serverURL).toString(), }); this.auth = options.auth; } get type() { return 'remote' as const; } private getWebSocketServerURL(serverURL: URL) { const webSocketServerURL = new URL(serverURL); webSocketServerURL.protocol = serverURL.protocol.replace(/^http(s)?:$/, 'ws$1:'); return webSocketServerURL; } async start() { await super.sharedStart(async () => { this.webSocketClient.onChannel('event', 'interceptors/responses/create', this.createResponse); this.webSocketClient.onChannel('event', 'interceptors/responses/unhandled', this.handleUnhandledServerRequest); await this.webSocketClient.start({ parameters: this.auth ? { token: this.auth.token } : undefined, waitForAuthentication: true, }); this.platform = this.readPlatform(); this.isRunning = true; }); } private createResponse = async ( message: WebSocketEventMessage<InterceptorServerWebSocketSchema, 'interceptors/responses/create'>, ) => { const { handlerId, request: serializedRequest } = message.data; const handler = this.httpHandlers.get(handlerId); const request = deserializeRequest(serializedRequest); try { const rawResponse = (await handler?.createResponse({ request })) ?? null; const response = rawResponse && request.method === 'HEAD' ? new Response(null, rawResponse) : rawResponse; if (response) { return { response: await serializeResponse(response) }; } } catch (error) { console.error(error); } const strategy = await super.getUnhandledRequestStrategy(request, 'remote'); await super.logUnhandledRequestIfNecessary(request, strategy); return { response: null }; }; private handleUnhandledServerRequest = async ( message: WebSocketEventMessage<InterceptorServerWebSocketSchema, 'interceptors/responses/unhandled'>, ) => { const { request: serializedRequest } = message.data; const request = deserializeRequest(serializedRequest); const strategy = await super.getUnhandledRequestStrategy(request, 'remote'); const { wasLogged } = await super.logUnhandledRequestIfNecessary(request, strategy); return { wasLogged }; }; private readPlatform(): HttpInterceptorPlatform { if (isServerSide()) { return 'node'; } /* istanbul ignore else -- @preserve */ if (isClientSide()) { return 'browser'; } /* istanbul ignore next -- @preserve * Ignoring because checking unknown platforms is not configured in our test setup. */ throw new UnknownHttpInterceptorPlatformError(); } async stop() { await super.sharedStop(async () => { this.webSocketClient.offChannel('event', 'interceptors/responses/create', this.createResponse); this.webSocketClient.offChannel('event', 'interceptors/responses/unhandled', this.handleUnhandledServerRequest); await this.clearHandlers(); await this.webSocketClient.stop(); this.isRunning = false; }); } async use<Schema extends HttpSchema>( interceptor: HttpInterceptorClient<Schema>, method: HttpMethod, path: string, createResponse: MSWHttpResponseFactory, ) { if (!this.isRunning) { throw new NotRunningHttpInterceptorError(); } validatePathParams(path); const crypto = await importCrypto(); const handler: HttpHandler = { id: crypto.randomUUID(), baseURL: interceptor.baseURLAsString, method, path, interceptor, async createResponse(context) { const response = await createResponse(context); return response; }, }; this.httpHandlers.set(handler.id, handler); await this.webSocketClient.request('interceptors/workers/commit', { id: handler.id, baseURL: handler.baseURL, method: handler.method, path: handler.path, }); } async clearHandlers<Schema extends HttpSchema>( options: { interceptor?: HttpInterceptorClient<Schema>; } = {}, ) { if (!this.isRunning) { throw new NotRunningHttpInterceptorError(); } if (options.interceptor === undefined) { this.httpHandlers.clear(); } else { for (const handler of this.httpHandlers.values()) { if (handler.interceptor === options.interceptor) { this.httpHandlers.delete(handler.id); } } } if (!this.webSocketClient.isRunning) { return; } const handlersToRecommit = Array.from<HttpHandler, HttpHandlerCommit>(this.httpHandlers.values(), (handler) => ({ id: handler.id, baseURL: handler.baseURL, method: handler.method, path: handler.path, })); try { await this.webSocketClient.request('interceptors/workers/reset', handlersToRecommit); } 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 server closes the socket before sending a response. In this case, * we can safely ignore the error because we know that the server is shutting down and resetting is no longer * necessary. * * 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; } } } get interceptorsWithHandlers() { const interceptors = Array.from(this.httpHandlers.values(), (handler) => handler.interceptor); return interceptors; } } export default RemoteHttpInterceptorWorker;