@zimic/interceptor
Version:
Next-gen TypeScript-first HTTP intercepting and mocking
288 lines (232 loc) • 8.71 kB
text/typescript
import { HttpRequest, HttpResponse, HttpMethod, HttpSchema } from '@zimic/http';
import createRegexFromPath from '@zimic/utils/url/createRegexFromPath';
import excludeNonPathParams from '@zimic/utils/url/excludeNonPathParams';
import validatePathParams from '@zimic/utils/url/validatePathParams';
import { SharedOptions as MSWWorkerSharedOptions, http, passthrough } from 'msw';
import * as mswBrowser from 'msw/browser';
import * as mswNode from 'msw/node';
import { removeArrayIndex } from '@/utils/arrays';
import { isClientSide, isServerSide } from '@/utils/environment';
import NotRunningHttpInterceptorError from '../interceptor/errors/NotRunningHttpInterceptorError';
import UnknownHttpInterceptorPlatformError from '../interceptor/errors/UnknownHttpInterceptorPlatformError';
import HttpInterceptorClient, { AnyHttpInterceptorClient } from '../interceptor/HttpInterceptorClient';
import UnregisteredBrowserServiceWorkerError from './errors/UnregisteredBrowserServiceWorkerError';
import HttpInterceptorWorker from './HttpInterceptorWorker';
import { HttpResponseFactoryContext } from './types/http';
import { BrowserMSWWorker, MSWHttpResponseFactory, MSWWorker, NodeMSWWorker } from './types/msw';
import { LocalHttpInterceptorWorkerOptions } from './types/options';
interface HttpHandler {
baseURL: string;
method: HttpMethod;
pathRegex: RegExp;
interceptor: AnyHttpInterceptorClient;
createResponse: (context: HttpResponseFactoryContext) => Promise<Response>;
}
class LocalHttpInterceptorWorker extends HttpInterceptorWorker {
private internalWorker?: MSWWorker;
private httpHandlersByMethod: {
[Method in HttpMethod]: HttpHandler[];
} = {
GET: [],
POST: [],
PATCH: [],
PUT: [],
DELETE: [],
HEAD: [],
OPTIONS: [],
};
constructor(_options: LocalHttpInterceptorWorkerOptions) {
super();
}
get type() {
return 'local' as const;
}
get internalWorkerOrThrow() {
/* istanbul ignore if -- @preserve
* Trying to access the internal worker when it does not exist should not happen. */
if (!this.internalWorker) {
throw new NotRunningHttpInterceptorError();
}
return this.internalWorker;
}
get internalWorkerOrCreate() {
this.internalWorker ??= this.createInternalWorker();
return this.internalWorker;
}
private createInternalWorker() {
const mswHttpHandler = http.all('*', async (context) => {
const request = context.request satisfies Request as HttpRequest;
const response = await this.createResponseForRequest(request);
return response;
});
if (isServerSide() && 'setupServer' in mswNode) {
return mswNode.setupServer(mswHttpHandler);
}
/* istanbul ignore else -- @preserve */
if (isClientSide() && 'setupWorker' in mswBrowser) {
return mswBrowser.setupWorker(mswHttpHandler);
}
/* istanbul ignore next -- @preserve
* Ignoring because checking unknown platforms is not configured in our test setup. */
throw new UnknownHttpInterceptorPlatformError();
}
async start() {
await super.sharedStart(async () => {
const internalWorker = this.internalWorkerOrCreate;
const sharedOptions: MSWWorkerSharedOptions = {
onUnhandledRequest: 'bypass',
};
if (this.isInternalBrowserWorker(internalWorker)) {
this.platform = 'browser';
await this.startInBrowser(internalWorker, sharedOptions);
} else {
this.platform = 'node';
this.startInNode(internalWorker, sharedOptions);
}
this.isRunning = true;
});
}
private async startInBrowser(internalWorker: BrowserMSWWorker, sharedOptions: MSWWorkerSharedOptions) {
try {
await internalWorker.start({ ...sharedOptions, quiet: true });
} catch (error) {
this.handleBrowserWorkerStartError(error);
}
}
private handleBrowserWorkerStartError(error: unknown) {
if (UnregisteredBrowserServiceWorkerError.matchesRawError(error)) {
throw new UnregisteredBrowserServiceWorkerError();
}
throw error;
}
private startInNode(internalWorker: NodeMSWWorker, sharedOptions: MSWWorkerSharedOptions) {
internalWorker.listen(sharedOptions);
}
async stop() {
await super.sharedStop(() => {
const internalWorker = this.internalWorkerOrCreate;
if (this.isInternalBrowserWorker(internalWorker)) {
this.stopInBrowser(internalWorker);
} else {
this.stopInNode(internalWorker);
}
this.clearHandlers();
this.internalWorker = undefined;
this.isRunning = false;
});
}
private stopInBrowser(internalWorker: BrowserMSWWorker) {
internalWorker.stop();
}
private stopInNode(internalWorker: NodeMSWWorker) {
internalWorker.close();
}
private isInternalBrowserWorker(worker: MSWWorker) {
return 'start' in worker && 'stop' in worker;
}
hasInternalBrowserWorker() {
return this.isInternalBrowserWorker(this.internalWorkerOrThrow);
}
hasInternalNodeWorker() {
return !this.hasInternalBrowserWorker();
}
use<Schema extends HttpSchema>(
interceptor: HttpInterceptorClient<Schema>,
method: HttpMethod,
path: string,
createResponse: MSWHttpResponseFactory,
) {
if (!this.isRunning) {
throw new NotRunningHttpInterceptorError();
}
validatePathParams(path);
const methodHandlers = this.httpHandlersByMethod[method];
const handler: HttpHandler = {
baseURL: interceptor.baseURLAsString,
method,
pathRegex: createRegexFromPath(path),
interceptor,
createResponse: async (context) => {
const request = context.request as HttpRequest;
const requestClone = request.clone();
let response: HttpResponse | null = null;
try {
response = await createResponse({ ...context, request });
} catch (error) {
console.error(error);
}
if (!response) {
return this.bypassOrRejectUnhandledRequest(requestClone);
}
if (context.request.method === 'HEAD') {
return new Response(null, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
return response;
},
};
methodHandlers.push(handler);
}
private async createResponseForRequest(request: HttpRequest) {
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;
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;
}
const response = await handler.createResponse({ request });
return response;
}
return this.bypassOrRejectUnhandledRequest(request);
}
private async bypassOrRejectUnhandledRequest(request: HttpRequest) {
const requestClone = request.clone();
const strategy = await super.getUnhandledRequestStrategy(request, 'local');
await super.logUnhandledRequestIfNecessary(requestClone, strategy);
if (strategy?.action === 'reject') {
return Response.error();
} else {
return passthrough();
}
}
clearHandlers<Schema extends HttpSchema>(
options: {
interceptor?: HttpInterceptorClient<Schema>;
} = {},
) {
if (!this.isRunning) {
throw new NotRunningHttpInterceptorError();
}
if (options.interceptor === undefined) {
for (const handlers of Object.values(this.httpHandlersByMethod)) {
handlers.length = 0;
}
} else {
for (const methodHandlers of Object.values(this.httpHandlersByMethod)) {
const groupToRemoveIndex = methodHandlers.findIndex((group) => group.interceptor === options.interceptor);
removeArrayIndex(methodHandlers, groupToRemoveIndex);
}
}
}
get interceptorsWithHandlers() {
const interceptors = new Set<AnyHttpInterceptorClient>();
for (const handlers of Object.values(this.httpHandlersByMethod)) {
for (const handler of handlers) {
interceptors.add(handler.interceptor);
}
}
return Array.from(interceptors);
}
}
export default LocalHttpInterceptorWorker;