@zimic/interceptor
Version:
Next-gen TypeScript-first HTTP intercepting and mocking
221 lines (175 loc) • 6.88 kB
text/typescript
import { HttpRequest, HttpResponse, HttpMethod, HttpSchema } from '@zimic/http';
import excludeURLParams from '@zimic/utils/url/excludeURLParams';
import validateURLPathParams from '@zimic/utils/url/validateURLPathParams';
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 from '../interceptor/HttpInterceptorClient';
import UnregisteredBrowserServiceWorkerError from './errors/UnregisteredBrowserServiceWorkerError';
import HttpInterceptorWorker from './HttpInterceptorWorker';
import { BrowserMSWWorker, MSWHandler, MSWHttpResponseFactory, MSWWorker, NodeMSWWorker } from './types/msw';
import { LocalHttpInterceptorWorkerOptions } from './types/options';
class LocalHttpInterceptorWorker extends HttpInterceptorWorker {
private internalWorker?: MSWWorker;
private defaultHttpHandler: MSWHandler;
private httpHandlerGroups: {
interceptor: HttpInterceptorClient<any>; // eslint-disable-line @typescript-eslint/no-explicit-any
httpHandler: MSWHandler;
}[] = [];
constructor(_options: LocalHttpInterceptorWorkerOptions) {
super();
this.defaultHttpHandler = http.all('*', async (context) => {
const request = context.request satisfies Request as HttpRequest;
return this.bypassOrRejectUnhandledRequest(request);
});
}
get type() {
return 'local' as const;
}
get internalWorkerOrThrow() {
if (!this.internalWorker) {
throw new NotRunningHttpInterceptorError();
}
return this.internalWorker;
}
get internalWorkerOrCreate() {
this.internalWorker ??= this.createInternalWorker();
return this.internalWorker;
}
private createInternalWorker() {
if (isServerSide() && 'setupServer' in mswNode) {
return mswNode.setupServer(this.defaultHttpHandler);
}
/* istanbul ignore else -- @preserve */
if (isClientSide() && 'setupWorker' in mswBrowser) {
return mswBrowser.setupWorker(this.defaultHttpHandler);
}
/* 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,
rawURL: string | URL,
createResponse: MSWHttpResponseFactory,
) {
const lowercaseMethod = method.toLowerCase<typeof method>();
const url = new URL(rawURL);
excludeURLParams(url);
validateURLPathParams(url);
const httpHandler = http[lowercaseMethod](url.toString(), 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;
});
this.internalWorkerOrThrow.use(httpHandler);
this.httpHandlerGroups.push({ interceptor, httpHandler });
}
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() {
this.internalWorkerOrThrow.resetHandlers();
this.httpHandlerGroups = [];
}
clearInterceptorHandlers<Schema extends HttpSchema>(interceptor: HttpInterceptorClient<Schema>) {
const groupToRemoveIndex = this.httpHandlerGroups.findIndex((group) => group.interceptor === interceptor);
removeArrayIndex(this.httpHandlerGroups, groupToRemoveIndex);
this.internalWorkerOrThrow.resetHandlers();
for (const { httpHandler } of this.httpHandlerGroups) {
this.internalWorkerOrThrow.use(httpHandler);
}
}
get interceptorsWithHandlers() {
return this.httpHandlerGroups.map((group) => group.interceptor);
}
}
export default LocalHttpInterceptorWorker;