Spaces:
Paused
Paused
| import { Logger } from '@open-draft/logger' | |
| import { Emitter, Listener } from 'strict-event-emitter' | |
| export type InterceptorEventMap = Record<string, any> | |
| export type InterceptorSubscription = () => void | |
| /** | |
| * Request header name to detect when a single request | |
| * is being handled by nested interceptors (XHR -> ClientRequest). | |
| * Obscure by design to prevent collisions with user-defined headers. | |
| * Ideally, come up with the Interceptor-level mechanism for this. | |
| * @see https://github.com/mswjs/interceptors/issues/378 | |
| */ | |
| export const INTERNAL_REQUEST_ID_HEADER_NAME = | |
| 'x-interceptors-internal-request-id' | |
| export function getGlobalSymbol<V>(symbol: Symbol): V | undefined { | |
| return ( | |
| // @ts-ignore https://github.com/Microsoft/TypeScript/issues/24587 | |
| globalThis[symbol] || undefined | |
| ) | |
| } | |
| function setGlobalSymbol(symbol: Symbol, value: any): void { | |
| // @ts-ignore | |
| globalThis[symbol] = value | |
| } | |
| export function deleteGlobalSymbol(symbol: Symbol): void { | |
| // @ts-ignore | |
| delete globalThis[symbol] | |
| } | |
| export enum InterceptorReadyState { | |
| INACTIVE = 'INACTIVE', | |
| APPLYING = 'APPLYING', | |
| APPLIED = 'APPLIED', | |
| DISPOSING = 'DISPOSING', | |
| DISPOSED = 'DISPOSED', | |
| } | |
| export type ExtractEventNames<Events extends Record<string, any>> = | |
| Events extends Record<infer EventName, any> ? EventName : never | |
| export class Interceptor<Events extends InterceptorEventMap> { | |
| protected emitter: Emitter<Events> | |
| protected subscriptions: Array<InterceptorSubscription> | |
| protected logger: Logger | |
| public readyState: InterceptorReadyState | |
| constructor(private readonly symbol: symbol) { | |
| this.readyState = InterceptorReadyState.INACTIVE | |
| this.emitter = new Emitter() | |
| this.subscriptions = [] | |
| this.logger = new Logger(symbol.description!) | |
| // Do not limit the maximum number of listeners | |
| // so not to limit the maximum amount of parallel events emitted. | |
| this.emitter.setMaxListeners(0) | |
| this.logger.info('constructing the interceptor...') | |
| } | |
| /** | |
| * Determine if this interceptor can be applied | |
| * in the current environment. | |
| */ | |
| protected checkEnvironment(): boolean { | |
| return true | |
| } | |
| /** | |
| * Apply this interceptor to the current process. | |
| * Returns an already running interceptor instance if it's present. | |
| */ | |
| public apply(): void { | |
| const logger = this.logger.extend('apply') | |
| logger.info('applying the interceptor...') | |
| if (this.readyState === InterceptorReadyState.APPLIED) { | |
| logger.info('intercepted already applied!') | |
| return | |
| } | |
| const shouldApply = this.checkEnvironment() | |
| if (!shouldApply) { | |
| logger.info('the interceptor cannot be applied in this environment!') | |
| return | |
| } | |
| this.readyState = InterceptorReadyState.APPLYING | |
| // Whenever applying a new interceptor, check if it hasn't been applied already. | |
| // This enables to apply the same interceptor multiple times, for example from a different | |
| // interceptor, only proxying events but keeping the stubs in a single place. | |
| const runningInstance = this.getInstance() | |
| if (runningInstance) { | |
| logger.info('found a running instance, reusing...') | |
| // Proxy any listeners you set on this instance to the running instance. | |
| this.on = (event, listener) => { | |
| logger.info('proxying the "%s" listener', event) | |
| // Add listeners to the running instance so they appear | |
| // at the top of the event listeners list and are executed first. | |
| runningInstance.emitter.addListener(event, listener) | |
| // Ensure that once this interceptor instance is disposed, | |
| // it removes all listeners it has appended to the running interceptor instance. | |
| this.subscriptions.push(() => { | |
| runningInstance.emitter.removeListener(event, listener) | |
| logger.info('removed proxied "%s" listener!', event) | |
| }) | |
| return this | |
| } | |
| this.readyState = InterceptorReadyState.APPLIED | |
| return | |
| } | |
| logger.info('no running instance found, setting up a new instance...') | |
| // Setup the interceptor. | |
| this.setup() | |
| // Store the newly applied interceptor instance globally. | |
| this.setInstance() | |
| this.readyState = InterceptorReadyState.APPLIED | |
| } | |
| /** | |
| * Setup the module augments and stubs necessary for this interceptor. | |
| * This method is not run if there's a running interceptor instance | |
| * to prevent instantiating an interceptor multiple times. | |
| */ | |
| protected setup(): void {} | |
| /** | |
| * Listen to the interceptor's public events. | |
| */ | |
| public on<EventName extends ExtractEventNames<Events>>( | |
| event: EventName, | |
| listener: Listener<Events[EventName]> | |
| ): this { | |
| const logger = this.logger.extend('on') | |
| if ( | |
| this.readyState === InterceptorReadyState.DISPOSING || | |
| this.readyState === InterceptorReadyState.DISPOSED | |
| ) { | |
| logger.info('cannot listen to events, already disposed!') | |
| return this | |
| } | |
| logger.info('adding "%s" event listener:', event, listener) | |
| this.emitter.on(event, listener) | |
| return this | |
| } | |
| public once<EventName extends ExtractEventNames<Events>>( | |
| event: EventName, | |
| listener: Listener<Events[EventName]> | |
| ): this { | |
| this.emitter.once(event, listener) | |
| return this | |
| } | |
| public off<EventName extends ExtractEventNames<Events>>( | |
| event: EventName, | |
| listener: Listener<Events[EventName]> | |
| ): this { | |
| this.emitter.off(event, listener) | |
| return this | |
| } | |
| public removeAllListeners<EventName extends ExtractEventNames<Events>>( | |
| event?: EventName | |
| ): this { | |
| this.emitter.removeAllListeners(event) | |
| return this | |
| } | |
| /** | |
| * Disposes of any side-effects this interceptor has introduced. | |
| */ | |
| public dispose(): void { | |
| const logger = this.logger.extend('dispose') | |
| if (this.readyState === InterceptorReadyState.DISPOSED) { | |
| logger.info('cannot dispose, already disposed!') | |
| return | |
| } | |
| logger.info('disposing the interceptor...') | |
| this.readyState = InterceptorReadyState.DISPOSING | |
| if (!this.getInstance()) { | |
| logger.info('no interceptors running, skipping dispose...') | |
| return | |
| } | |
| // Delete the global symbol as soon as possible, | |
| // indicating that the interceptor is no longer running. | |
| this.clearInstance() | |
| logger.info('global symbol deleted:', getGlobalSymbol(this.symbol)) | |
| if (this.subscriptions.length > 0) { | |
| logger.info('disposing of %d subscriptions...', this.subscriptions.length) | |
| for (const dispose of this.subscriptions) { | |
| dispose() | |
| } | |
| this.subscriptions = [] | |
| logger.info('disposed of all subscriptions!', this.subscriptions.length) | |
| } | |
| this.emitter.removeAllListeners() | |
| logger.info('destroyed the listener!') | |
| this.readyState = InterceptorReadyState.DISPOSED | |
| } | |
| private getInstance(): this | undefined { | |
| const instance = getGlobalSymbol<this>(this.symbol) | |
| this.logger.info('retrieved global instance:', instance?.constructor?.name) | |
| return instance | |
| } | |
| private setInstance(): void { | |
| setGlobalSymbol(this.symbol, this) | |
| this.logger.info('set global instance!', this.symbol.description) | |
| } | |
| private clearInstance(): void { | |
| deleteGlobalSymbol(this.symbol) | |
| this.logger.info('cleared global instance!', this.symbol.description) | |
| } | |
| } | |