import type { Logger } from '@rocket.chat/logger'; import { Random } from '@rocket.chat/random'; import { compareByRanking } from '../../../lib/utils/comparisons'; enum CallbackPriority { HIGH = -1000, MEDIUM = 0, LOW = 1000, } type Callback = { (item: unknown, constant?: unknown): Promise; hook: H; id: string; priority: CallbackPriority; stack: string; }; type CallbackTracker = (callback: Callback) => () => void; type HookTracker = (params: { hook: H; length: number }) => () => void; export class Callbacks< TChainedCallbackSignatures extends { [key: string]: (item: any, constant?: any) => any; }, TEventLikeCallbackSignatures extends { [key: string]: (item: any, constant?: any) => any; }, THook extends string = keyof TChainedCallbackSignatures & keyof TEventLikeCallbackSignatures & string, > { private logger: Logger | undefined = undefined; private trackCallback: CallbackTracker | undefined = undefined; private trackHook: HookTracker | undefined = undefined; private callbacks = new Map[]>(); private sequentialRunners = new Map Promise>(); private asyncRunners = new Map unknown>(); readonly priority = CallbackPriority; setLogger(logger: Logger): void { this.logger = logger; } setMetricsTrackers({ trackCallback, trackHook }: { trackCallback?: CallbackTracker; trackHook?: HookTracker }): void { this.trackCallback = trackCallback; this.trackHook = trackHook; } private runOne(callback: Callback, item: unknown, constant: unknown): Promise { const stopTracking = this.trackCallback?.(callback); return Promise.resolve(callback(item, constant)).finally(stopTracking); } private createSequentialRunner(hook: THook, callbacks: Callback[]): (item: unknown, constant?: unknown) => Promise { const wrapCallback = (callback: Callback) => async (item: unknown, constant?: unknown): Promise => { this.logger?.debug(`Executing callback with id ${callback.id} for hook ${callback.hook}`); return (await this.runOne(callback, item, constant)) ?? item; }; const identity = (item: TItem): Promise => Promise.resolve(item); const pipe = (curr: (item: unknown, constant?: unknown) => Promise, next: (item: unknown, constant?: unknown) => Promise) => async (item: unknown, constant?: unknown): Promise => next(await curr(item, constant), constant); const fn = callbacks.map(wrapCallback).reduce(pipe, identity); return async (item: unknown, constant?: unknown): Promise => { const stopTracking = this.trackHook?.({ hook, length: callbacks.length }); return fn(item, constant).finally(() => stopTracking?.()); }; } private createAsyncRunner(_: THook, callbacks: Callback[]) { return (item: unknown, constant?: unknown): unknown => { if (typeof window !== 'undefined') { throw new Error('callbacks.runAsync on client server not allowed'); } for (const callback of callbacks) { setTimeout(() => { void this.runOne(callback, item, constant); }, 0); } return item; }; } getCallbacks(hook: THook): Callback[] { return this.callbacks.get(hook) ?? []; } setCallbacks(hook: THook, callbacks: Callback[]): void { this.callbacks.set(hook, callbacks); this.sequentialRunners.set(hook, this.createSequentialRunner(hook, callbacks)); this.asyncRunners.set(hook, this.createAsyncRunner(hook, callbacks)); } /** * Add a callback function to a hook * * @param hook the name of the hook * @param callback the callback function * @param priority the callback run priority (order) * @param id human friendly name for this callback */ add( hook: Hook, callback: TEventLikeCallbackSignatures[Hook], priority?: CallbackPriority, id?: string, ): () => void; add( hook: Hook, callback: TChainedCallbackSignatures[Hook], priority?: CallbackPriority, id?: string, ): () => void; add( hook: THook, callback: (item: TItem, constant?: TConstant) => TNextItem, priority?: CallbackPriority, id?: string, ): () => void; add( hook: THook, callback: (item: unknown, constant?: unknown) => unknown, priority = this.priority.MEDIUM, id = Random.id(), ): () => void { const callbacks = this.getCallbacks(hook); if (callbacks.some((cb) => cb.id === id)) { return () => { this.remove(hook, id); }; } callbacks.push( Object.assign(callback as Callback, { hook, priority, id, stack: new Error().stack, }), ); callbacks.sort(compareByRanking((callback: Callback): number => callback.priority ?? this.priority.MEDIUM)); this.setCallbacks(hook, callbacks); return () => { this.remove(hook, id); }; } /** * Remove a callback from a hook * * @param hook the name of the hook * @param id the callback's id */ remove(hook: THook, id: string): void { const hooks = this.getCallbacks(hook).filter((callback) => callback.id !== id); this.setCallbacks(hook, hooks); } run(hook: Hook, ...args: Parameters): Promise; run( hook: Hook, ...args: Parameters ): Promise>; run(hook: THook, item: TItem, constant?: TConstant): Promise; /** * Successively run all of a hook's callbacks on an item * * @param hook the name of the hook * @param item the post, comment, modifier, etc. on which to run the callbacks * @param constant an optional constant that will be passed along to each callback * @returns returns the item after it's been through all the callbacks for this hook */ run(hook: THook, item: unknown, constant?: unknown): Promise { const runner = this.sequentialRunners.get(hook) ?? (async (item: unknown, _constant?: unknown): Promise => item); return runner(item, constant); } runAsync(hook: Hook, ...args: Parameters): void; /** * Successively run all of a hook's callbacks on an item, in async mode (only works on server) * * @param hook the name of the hook * @param item the post, comment, modifier, etc. on which to run the callbacks * @param constant an optional constant that will be passed along to each callback * @returns the post, comment, modifier, etc. on which to run the callbacks */ runAsync(hook: THook, item: unknown, constant?: unknown): unknown { const runner = this.asyncRunners.get(hook) ?? ((item: unknown, _constant?: unknown): unknown => item); return runner(item, constant); } static create any | Promise>( hook: string, ): Cb[0], ReturnType, Parameters[1]>; static create(hook: string): Cb { const callbacks = new Callbacks(); return { add: (callback, priority, id) => callbacks.add(hook as any, callback, priority, id), remove: (id) => callbacks.remove(hook as any, id), run: (item, constant) => callbacks.run(hook as any, item, constant) as any, }; } } /** * Callback hooks provide an easy way to add extra steps to common operations. * @deprecated */ type Cb = { add: ( callback: (item: I, constant: C) => Promise | R | undefined | void, priority?: CallbackPriority, id?: string, ) => void; remove: (id: string) => void; run: (item: I, constant?: C) => Promise; };