import { Emitter } from '@rocket.chat/emitter'; import _ from 'underscore'; import type { ISetting, SettingValue } from '@rocket.chat/core-typings'; import { SystemLogger } from '../../../server/lib/logger/system'; const warn = process.env.NODE_ENV === 'development' || process.env.TEST_MODE; type SettingsConfig = { debounce: number; }; type OverCustomSettingsConfig = Partial; export interface ICachedSettings { /* * @description: The settings object as ready */ initialized(): void; has(_id: ISetting['_id']): boolean; getSetting(_id: ISetting['_id']): ISetting | undefined; get(_id: ISetting['_id']): T; getByRegexp(_id: RegExp): [string, T][]; watchMultiple(_id: ISetting['_id'][], callback: (settings: T[]) => void): () => void; watch(_id: ISetting['_id'], cb: (args: T) => void, config?: OverCustomSettingsConfig): () => void; watchOnce( _id: ISetting['_id'], cb: (args: T) => void, config?: OverCustomSettingsConfig, ): () => void; change( _id: ISetting['_id'], callback: (args: T) => void, config?: OverCustomSettingsConfig, ): () => void; changeMultiple( _ids: ISetting['_id'][], callback: (settings: T[]) => void, config?: OverCustomSettingsConfig, ): () => void; changeOnce( _id: ISetting['_id'], callback: (args: T) => void, config?: OverCustomSettingsConfig, ): () => void; set(record: ISetting): void; getConfig(config?: OverCustomSettingsConfig): SettingsConfig; watchByRegex(regex: RegExp, cb: (...args: [string, SettingValue]) => void, config?: OverCustomSettingsConfig): () => void; changeByRegex(regex: RegExp, callback: (...args: [string, SettingValue]) => void, config?: OverCustomSettingsConfig): () => void; onReady(cb: () => void): void; } /** * Class responsible for setting up the settings, cache and propagation changes * Should be agnostic to the actual settings implementation, running on meteor or standalone * * You should not instantiate this class directly, only for testing purposes * * @extends Emitter * @alpha */ export class CachedSettings extends Emitter< { '*': [string, SettingValue]; } & { ready: undefined; [k: string]: SettingValue; } > implements ICachedSettings { ready = false; store = new Map(); /** * The settings object as ready */ initialized(): void { if (this.ready) { return; } this.ready = true; this.emit('ready'); SystemLogger.debug('Settings initialized'); } /** * returns if the setting is defined * @param _id - The setting id * @returns {boolean} */ public has(_id: ISetting['_id']): boolean { if (!this.ready && warn) { SystemLogger.warn(`Settings not initialized yet. getting: ${_id}`); } return this.store.has(_id); } /** * Gets the current Object of the setting * @param _id - The setting id * @returns {ISetting} - The current Object of the setting */ public getSetting(_id: ISetting['_id']): ISetting | undefined { if (!this.ready && warn) { SystemLogger.warn(`Settings not initialized yet. getting: ${_id}`); } return this.store.get(_id); } /** * Gets the current value of the setting * - In development mode if you are trying to get the value of a setting that is not defined, it will give an warning, in theory it makes sense, there no reason to do that * - The setting's value will be cached in memory so it won't call the DB every time you fetch a particular setting * @param _id - The setting id * @returns {SettingValue} - The current value of the setting */ public get(_id: ISetting['_id']): T { if (!this.ready && warn) { SystemLogger.warn(`Settings not initialized yet. getting: ${_id}`); } return this.store.get(_id)?.value as T; } /** * Gets the current value of the setting * - In development mode if you are trying to get the value of a setting that is not defined, it will give an warning, in theory it makes sense, there no reason to do that * @deprecated * @param _id - The setting id * @returns {SettingValue} - The current value of the setting */ public getByRegexp(_id: RegExp): [string, T][] { if (!this.ready && warn) { SystemLogger.warn(`Settings not initialized yet. getting: ${_id}`); } return [...this.store.entries()].filter(([key]) => _id.test(key)).map(([key, setting]) => [key, setting.value]) as [string, T][]; } /** * Get the current value of the settings, and keep track of changes * - This callback is debounced * - The callback is not fire until the settings got initialized * @param _ids - Array of setting id * @param callback - The callback to run * @returns {() => void} - A function that can be used to cancel the observe */ public watchMultiple(_id: ISetting['_id'][], callback: (settings: T[]) => void): () => void { if (!this.ready) { const cancel = new Set<() => void>(); cancel.add( this.once('ready', (): void => { cancel.clear(); cancel.add(this.watchMultiple(_id, callback)); }), ); return (): void => { cancel.forEach((fn) => fn()); }; } if (_id.every((id) => this.store.has(id))) { const settings = _id.map((id) => this.store.get(id)?.value); callback(settings as T[]); } const mergeFunction = _.debounce((): void => { callback(_id.map((id) => this.store.get(id)?.value) as T[]); }, 100); const fns = _id.map((id) => this.on(id, mergeFunction)); return (): void => { fns.forEach((fn) => fn()); }; } /** * Get the current value of the setting, and keep track of changes * - This callback is debounced * - The callback is not fire until the settings got initialized * @param _id - The setting id * @param callback - The callback to run * @returns {() => void} - A function that can be used to cancel the observe */ public watch( _id: ISetting['_id'], cb: (args: T) => void, config?: OverCustomSettingsConfig, ): () => void { if (!this.ready) { const cancel = new Set<() => void>(); cancel.add( this.once('ready', (): void => { cancel.clear(); cancel.add(this.watch(_id, cb, config)); }), ); return (): void => { cancel.forEach((fn) => fn()); }; } this.store.has(_id) && cb(this.store.get(_id)?.value as T); return this.change(_id, cb, config); } /** * Get the current value of the setting, or wait until the initialized * - This is a one time run * - This callback is debounced * - The callback is not fire until the settings got initialized * @param _id - The setting id * @param callback - The callback to run * @returns {() => void} - A function that can be used to cancel the observe */ public watchOnce( _id: ISetting['_id'], cb: (args: T) => void, config?: OverCustomSettingsConfig, ): () => void { if (this.store.has(_id)) { cb(this.store.get(_id)?.value as T); return (): void => undefined; } return this.changeOnce(_id, cb, config); } /** * Observes the given setting by id and keep track of changes * - This callback is debounced * - The callback is not fire until the setting is changed * - The callback is not fire until all the settings get initialized * @param _id - The setting id * @param callback - The callback to run * @returns {() => void} - A function that can be used to cancel the observe */ public change( _id: ISetting['_id'], callback: (args: T) => void, config?: OverCustomSettingsConfig, ): () => void { const { debounce } = this.getConfig(config); return this.on(_id, _.debounce(callback, debounce) as any); } /** * Observes multiple settings and keep track of changes * - This callback is debounced * - The callback is not fire until the setting is changed * - The callback is not fire until all the settings get initialized * @param _ids - Array of setting id * @param callback - The callback to run * @returns {() => void} - A function that can be used to cancel the observe */ public changeMultiple( _ids: ISetting['_id'][], callback: (settings: T[]) => void, config?: OverCustomSettingsConfig, ): () => void { const fns = _ids.map((id) => this.change( id, (): void => { callback(_ids.map((id) => this.store.get(id)?.value) as T[]); }, config, ), ); return (): void => { fns.forEach((fn) => fn()); }; } /** * Observes the setting and fires only if there is a change. Runs only once * - This is a one time run * - This callback is debounced * - The callback is not fire until the setting is changed * - The callback is not fire until all the settings get initialized * @param _id - The setting id * @param callback - The callback to run * @returns {() => void} - A function that can be used to cancel the observe */ public changeOnce( _id: ISetting['_id'], callback: (args: T) => void, config?: OverCustomSettingsConfig, ): () => void { const { debounce } = this.getConfig(config); return this.once(_id, _.debounce(callback, debounce) as any); } /** * Sets the value of the setting * - if the value set is the same as the current value, the change will not be fired * - if the value is set before the initialization, the emit will be queued and will be fired after initialization * @param _id - The setting id * @param value - The value to set * @returns {void} */ public set(record: ISetting): void { if (this.store.has(record._id) && this.store.get(record._id)?.value === record.value) { return; } this.store.set(record._id, record); if (!this.ready) { this.once('ready', () => { this.emit(record._id, this.store.get(record._id)?.value); this.emit('*', [record._id, this.store.get(record._id)?.value]); }); return; } this.emit(record._id, this.store.get(record._id)?.value); this.emit('*', [record._id, this.store.get(record._id)?.value]); } public getConfig = (config?: OverCustomSettingsConfig): SettingsConfig => ({ debounce: 500, ...config, }); /** @deprecated */ public watchByRegex(regex: RegExp, cb: (...args: [string, SettingValue]) => void, config?: OverCustomSettingsConfig): () => void { if (!this.ready) { const cancel = new Set<() => void>(); cancel.add( this.once('ready', (): void => { cancel.clear(); cancel.add(this.watchByRegex(regex, cb, config)); }), ); return (): void => { cancel.forEach((fn) => fn()); }; } [...this.store.entries()].forEach(([key, setting]) => { if (regex.test(key)) { cb(key, setting.value); } }); return this.changeByRegex(regex, cb, config); } /** @deprecated */ public changeByRegex(regex: RegExp, callback: (...args: [string, SettingValue]) => void, config?: OverCustomSettingsConfig): () => void { const store: Map void> = new Map(); return this.on('*', ([_id, value]) => { if (regex.test(_id)) { const { debounce } = this.getConfig(config); const cb = store.get(_id) || _.debounce(callback, debounce); cb(_id, value); store.set(_id, cb); } regex.lastIndex = 0; }); } /** * Wait until the settings get ready then run the callback */ public onReady(cb: () => void): void { if (this.ready) { return cb(); } this.once('ready', cb); } }