UNPKG

@cap-js-community/feature-toggle-library

Version:

SAP BTP feature toggle library enables Node.js applications using the SAP Cloud Application Programming Model to maintain live-updatable feature toggles via Redis.

1,526 lines (1,392 loc) 59.5 kB
/** * SAP BTP Feature Toggle Library * * {@link https://cap-js-community.github.io/feature-toggle-library/ Documentation} * * important usage functions: * @see FeatureToggles#getFeatureValue * @see FeatureToggles#changeFeatureValue * @see FeatureToggles#registerFeatureValueChangeHandler */ "use strict"; // TODO locale for validation messages const util = require("util"); const pathlib = require("path"); const fs = require("fs"); const VError = require("verror"); const yaml = require("yaml"); const redis = require("./redis-adapter"); const { REDIS_INTEGRATION_MODE } = redis; const { CfEnv } = require("./shared/cf-env"); const { Logger } = require("./shared/logger"); const { HandlerCollection } = require("./shared/handler-collection"); const { LimitedLazyCache } = require("./shared/cache"); const { isObject, tryRequire, tryPathReadable, tryJsonParse } = require("./shared/static"); const ENV = Object.freeze({ UNIQUE_NAME: "BTP_FEATURES_UNIQUE_NAME", REDIS_KEY: "BTP_FEATURES_REDIS_KEY", REDIS_CHANNEL: "BTP_FEATURES_REDIS_CHANNEL", }); const ENV_UNIQUE_NAME = process.env[ENV.UNIQUE_NAME]; const DEFAULT_REDIS_CHANNEL = process.env[ENV.REDIS_CHANNEL] || "features"; const DEFAULT_REDIS_KEY = process.env[ENV.REDIS_KEY] || "features"; const DEFAULT_CONFIG_FILEPATH = pathlib.join(process.cwd(), ".features.yaml"); const FEATURE_VALID_TYPES = ["string", "number", "boolean"]; const SUPER_SCOPE_CACHE_SIZE_LIMIT = 15; const SCOPE_KEY_INNER_SEPARATOR = "::"; const SCOPE_KEY_OUTER_SEPARATOR = "##"; const SCOPE_ROOT_KEY = "//"; const CONFIG_SOURCE = Object.freeze({ NONE: "NONE", // for toggles that are not configured RUNTIME: "RUNTIME", FILE: "FILE", AUTO: "AUTO", }); const CONFIG_MERGE_CONFLICT = Object.freeze({ THROW: "THROW", PRESERVE: "PRESERVE", OVERRIDE: "OVERRIDE", }); const CONFIG_KEY = Object.freeze({ TYPE: "TYPE", ACTIVE: "ACTIVE", SOURCE: "SOURCE", SOURCE_FILEPATH: "SOURCE_FILEPATH", APP_URL: "APP_URL", APP_URL_ACTIVE: "APP_URL_ACTIVE", VALIDATIONS: "VALIDATIONS", VALIDATIONS_SCOPES_MAP: "VALIDATIONS_SCOPES_MAP", VALIDATIONS_REGEX: "VALIDATIONS_REGEX", }); const CONFIG_INFO_KEY = { [CONFIG_KEY.TYPE]: true, [CONFIG_KEY.ACTIVE]: true, [CONFIG_KEY.SOURCE]: true, [CONFIG_KEY.SOURCE_FILEPATH]: true, [CONFIG_KEY.APP_URL]: true, [CONFIG_KEY.APP_URL_ACTIVE]: true, [CONFIG_KEY.VALIDATIONS]: true, }; const COMPONENT_NAME = "/FeatureToggles"; const VERROR_CLUSTER_NAME = "FeatureTogglesError"; const SCOPE_PREFERENCE_ORDER_MASKS = [ [ // choose 1 of 2 parseInt("10", 2), parseInt("01", 2), ], [ // choose 2 of 3 parseInt("110", 2), parseInt("101", 2), parseInt("011", 2), // choose 1 of 3 parseInt("100", 2), parseInt("010", 2), parseInt("001", 2), ], [ // choose 3 of 4 parseInt("1110", 2), parseInt("1101", 2), parseInt("1011", 2), parseInt("0111", 2), // choose 2 of 4 parseInt("1100", 2), parseInt("1010", 2), parseInt("1001", 2), parseInt("0110", 2), parseInt("0101", 2), parseInt("0011", 2), // choose 1 of 4 parseInt("1000", 2), parseInt("0100", 2), parseInt("0010", 2), parseInt("0001", 2), ], ]; const cfEnv = CfEnv.getInstance(); const readFileAsync = util.promisify(fs.readFile); let logger = new Logger(COMPONENT_NAME); /** * FeatureToggles main library API class. */ class FeatureToggles { static __instance; // ======================================== // START OF CONSTRUCTOR SECTION // ======================================== static _getDefaultUniqueName() { if (ENV_UNIQUE_NAME) { return ENV_UNIQUE_NAME; } let cfApp; try { cfApp = cfEnv.cfApp; if (cfApp.application_name) { return cfApp.application_name; } } catch (err) { throw new VError( { name: VERROR_CLUSTER_NAME, cause: err, info: { cfApp: JSON.stringify(cfApp), }, }, "error determining cf app name" ); } } _processValidations(featureKey, validations, configFilepath) { const configDir = configFilepath ? pathlib.dirname(configFilepath) : process.cwd(); const validationsScopesMap = {}; const validationsRegex = []; const validationsCode = []; for (const validation of validations) { if (Array.isArray(validation.scopes)) { for (const scope of validation.scopes) { validationsScopesMap[scope] = true; } continue; } if (validation.regex) { validationsRegex.push(new RegExp(validation.regex)); continue; } if (validation.module) { const modulePath = validation.module.replace("$CONFIG_DIR", configDir); const validatorModule = tryRequire(pathlib.resolve(modulePath)); const validator = validation.call ? validatorModule?.[validation.call] : validatorModule; const validatorType = typeof validator; if (validatorType === "function") { validationsCode.push(validator); } else { logger.warning( new VError( { name: VERROR_CLUSTER_NAME, info: { featureKey, validation: JSON.stringify(validation), modulePath, validatorType, }, }, "could not load module validation" ) ); } continue; } throw new VError( { name: VERROR_CLUSTER_NAME, info: { featureKey, validation: JSON.stringify(validation), }, }, "found invalid validation" ); } if (Object.keys(validationsScopesMap).length > 0) { this.__config[featureKey][CONFIG_KEY.VALIDATIONS_SCOPES_MAP] = validationsScopesMap; } if (validationsRegex.length > 0) { this.__config[featureKey][CONFIG_KEY.VALIDATIONS_REGEX] = validationsRegex; } for (const validator of validationsCode) { this.registerFeatureValueValidation(featureKey, validator); } } _processConfigSource(source, mergeConflictBehavior, configFromSource, sourceFilepath) { let count = 0; if (!isObject(configFromSource)) { return count; } const { uris: cfAppUris } = cfEnv.cfApp; const entries = Object.entries(configFromSource); for (const [featureKey, value] of entries) { if (this.__config[featureKey]) { switch (mergeConflictBehavior) { case CONFIG_MERGE_CONFLICT.OVERRIDE: { break; } case CONFIG_MERGE_CONFLICT.PRESERVE: { continue; } case CONFIG_MERGE_CONFLICT.THROW: // eslint-disable-current-line no-fallthrough default: { const sourceExisting = this.__config[featureKey][CONFIG_KEY.SOURCE]; const sourceConflicting = source; const sourceFilepathExisting = this.__config[featureKey][CONFIG_KEY.SOURCE_FILEPATH]; const sourceFilepathConflicting = sourceFilepath; throw new VError( { name: VERROR_CLUSTER_NAME, info: { featureKey, sourceExisting, sourceConflicting, ...(sourceFilepathExisting && { sourceFilepathExisting }), ...(sourceFilepathConflicting && { sourceFilepathConflicting }), }, }, "feature is configured twice" ); } } } count++; if (!isObject(value)) { throw new VError( { name: VERROR_CLUSTER_NAME, info: { featureKey, source, ...(sourceFilepath && { sourceFilepath }), }, }, "configuration is not an object" ); } const { type, active, appUrl, fallbackValue, validations } = value; if ([undefined, null].includes(fallbackValue)) { throw new VError( { name: VERROR_CLUSTER_NAME, info: { featureKey, source, ...(sourceFilepath && { sourceFilepath }), }, }, "configuration has no or invalid fallback value" ); } if (!FEATURE_VALID_TYPES.includes(type)) { throw new VError( { name: VERROR_CLUSTER_NAME, info: { featureKey, source, ...(sourceFilepath && { sourceFilepath }), }, }, "configuration has no or invalid type" ); } this.__fallbackValues[featureKey] = fallbackValue; this.__config[featureKey] = {}; this.__config[featureKey][CONFIG_KEY.TYPE] = type; this.__config[featureKey][CONFIG_KEY.SOURCE] = source; if (sourceFilepath) { this.__config[featureKey][CONFIG_KEY.SOURCE_FILEPATH] = sourceFilepath; } if (active === false) { this.__config[featureKey][CONFIG_KEY.ACTIVE] = false; } if (appUrl) { this.__config[featureKey][CONFIG_KEY.APP_URL] = appUrl; const appUrlRegex = new RegExp(appUrl); if (Array.isArray(cfAppUris) && cfAppUris.every((cfAppUri) => !appUrlRegex.test(cfAppUri))) { this.__config[featureKey][CONFIG_KEY.APP_URL_ACTIVE] = false; } } if (validations) { this.__config[featureKey][CONFIG_KEY.VALIDATIONS] = validations; } } return count; } /** * Populate this.__config. */ _processConfig({ configAuto, configFromFilesEntries, configRuntime } = {}) { const configAutoCount = this._processConfigSource(CONFIG_SOURCE.AUTO, CONFIG_MERGE_CONFLICT.OVERRIDE, configAuto); const configFromFileCount = configFromFilesEntries.reduce( (count, [configFilepath, configFromFile]) => count + this._processConfigSource(CONFIG_SOURCE.FILE, CONFIG_MERGE_CONFLICT.OVERRIDE, configFromFile, configFilepath), 0 ); const configRuntimeCount = this._processConfigSource( CONFIG_SOURCE.RUNTIME, CONFIG_MERGE_CONFLICT.OVERRIDE, configRuntime ); // NOTE: this post-processing is easier to do after the configuration is merged this.__featureKeys = Object.keys(this.__fallbackValues); for (const featureKey of this.__featureKeys) { const validations = this.__config[featureKey][CONFIG_KEY.VALIDATIONS]; if (validations) { const sourceFilepath = this.__config[featureKey][CONFIG_KEY.SOURCE_FILEPATH]; this._processValidations(featureKey, validations, sourceFilepath); } } this.__isConfigProcessed = true; return { [CONFIG_SOURCE.AUTO]: configAutoCount, [CONFIG_SOURCE.RUNTIME]: configRuntimeCount, [CONFIG_SOURCE.FILE]: configFromFileCount, }; } _ensureInitialized() { if (this.__isInitialized) { return; } throw new VError( { name: VERROR_CLUSTER_NAME }, "feature toggles API called, but class instance is not initialized" ); } /** * Implementation for {@link constructor}. * * @param {ConstructorOptions} [options] */ _reset({ uniqueName = FeatureToggles._getDefaultUniqueName(), redisChannel = DEFAULT_REDIS_CHANNEL, redisKey = DEFAULT_REDIS_KEY, } = {}) { this.__uniqueName = uniqueName; this.__redisChannel = uniqueName ? redisChannel + "-" + uniqueName : redisChannel; this.__redisKey = uniqueName ? redisKey + "-" + uniqueName : redisKey; this.__featureValueChangeHandlers = new HandlerCollection(); this.__featureValueValidators = new HandlerCollection(); this.__messageHandler = this._messageHandler.bind(this); // needed for testing this.__superScopeCache = new LimitedLazyCache({ sizeLimit: SUPER_SCOPE_CACHE_SIZE_LIMIT }); this.__config = {}; this.__featureKeys = []; this.__fallbackValues = {}; this.__stateScopedValues = {}; this.__initializePromise = undefined; this.__isInitialized = false; this.__isConfigProcessed = false; } /** * @typedef ConstructorOptions * @type object * @property {string} [uniqueName] unique name to prefix both Redis channel and key * @property {string} [redisChannel] channel for Redis pub/sub to propagate changes across servers * @property {string} [redisKey] key in Redis to save non-fallback values */ /** * NOTE: constructors cannot be async, so we need to split this state preparation part from the initialize part * @param {ConstructorOptions} [options] */ constructor(options) { this._reset(options); } // ======================================== // END OF CONSTRUCTOR SECTION // ======================================== // ======================================== // START OF SINGLETON SECTION // ======================================== /** * Get singleton instance * * @returns {FeatureToggles} */ static getInstance() { if (!FeatureToggles.__instance) { FeatureToggles.__instance = new FeatureToggles(); } return FeatureToggles.__instance; } // ======================================== // END OF SINGLETON SECTION // ======================================== // ======================================== // START OF VALIDATION SECTION // ======================================== static _isValidFeatureKey(config, featureKey) { return typeof featureKey === "string" && Object.prototype.hasOwnProperty.call(config, featureKey); } static _isValidFeatureValueType(value) { return value === null || FEATURE_VALID_TYPES.includes(typeof value); } static _isValidScopeKey(scopeKey) { return scopeKey === undefined || typeof scopeKey === "string"; } static _isValidScopeMapValue(value) { return typeof value === "string"; } // NOTE: this function is used during initialization, so we cannot check this.__isInitialized async _validateFeatureValue(featureKey, value, { scopeMap, scopeKey, isChange = false, remoteOnly = false } = {}) { if (!this.__isConfigProcessed) { return [{ errorMessage: "not initialized" }]; } // NOTE: for remoteOnly we only allow values that are not configured if (remoteOnly) { if (this.__config[featureKey]) { return [{ featureKey, errorMessage: "remoteOnly is not allowed for configured toggles" }]; } return []; } if (!FeatureToggles._isValidFeatureKey(this.__config, featureKey)) { return [{ featureKey, errorMessage: "feature key is not valid" }]; } if (scopeMap !== undefined) { if (!isObject(scopeMap)) { return [ { featureKey, errorMessage: "scopeMap must be undefined or an object", }, ]; } const validationsScopesMap = this.__config[featureKey][CONFIG_KEY.VALIDATIONS_SCOPES_MAP]; for (const [scope, value] of Object.entries(scopeMap)) { if (!FeatureToggles._isValidScopeMapValue(value)) { return [ { featureKey, errorMessage: 'scope "{0}" has invalid type {1}, must be string', errorMessageValues: [scope, typeof value], }, ]; } if (validationsScopesMap && !validationsScopesMap[scope]) { return [ { featureKey, errorMessage: 'scope "{0}" is not allowed', errorMessageValues: [scope], }, ]; } } } if (!FeatureToggles._isValidScopeKey(scopeKey)) { return [{ featureKey, scopeKey, errorMessage: "scopeKey is not valid" }]; } // NOTE: value === null is our way of encoding featureKey resetting changes, so it is allowed for changes but not // for actual values if (value === null) { if (isChange) { return []; } else { return [{ featureKey, ...(scopeKey && { scopeKey }), errorMessage: "value null is not allowed" }]; } } // NOTE: skip validating active properties during initialization if (this.__isInitialized) { if (this.__config[featureKey][CONFIG_KEY.ACTIVE] === false) { return [{ featureKey, errorMessage: "feature key is not active" }]; } if (this.__config[featureKey][CONFIG_KEY.APP_URL_ACTIVE] === false) { return [ { featureKey, errorMessage: "feature key is not active because app url does not match regular expression {0}", errorMessageValues: [this.__config[featureKey][CONFIG_KEY.APP_URL]], }, ]; } } const valueType = typeof value; if (!FeatureToggles._isValidFeatureValueType(value)) { return [ { featureKey, ...(scopeKey && { scopeKey }), errorMessage: 'value "{0}" has invalid type {1}, must be in {2}', errorMessageValues: [value, valueType, FEATURE_VALID_TYPES], }, ]; } if (valueType !== this.__config[featureKey][CONFIG_KEY.TYPE]) { return [ { featureKey, ...(scopeKey && { scopeKey }), errorMessage: 'value "{0}" has invalid type {1}, must be {2}', errorMessageValues: [value, valueType, this.__config[featureKey][CONFIG_KEY.TYPE]], }, ]; } const validationsRegex = this.__config[featureKey][CONFIG_KEY.VALIDATIONS_REGEX]; if (Array.isArray(validationsRegex) && validationsRegex.length > 0) { const failingRegex = validationsRegex.find((validationRegex) => !validationRegex.test(value)); if (failingRegex) { return [ { featureKey, ...(scopeKey && { scopeKey }), errorMessage: 'value "{0}" does not match validation regular expression {1}', errorMessageValues: [value, failingRegex.toString()], }, ]; } } const validators = this.__featureValueValidators.getHandlers(featureKey); if (validators.length === 0) { return []; } const validatorErrors = await Promise.all( validators.map(async (validator) => { const validatorName = validator.name || "anonymous"; try { const validationErrorOrErrors = (await validator(value, scopeMap, scopeKey)) || []; const validationErrors = Array.isArray(validationErrorOrErrors) ? validationErrorOrErrors : [validationErrorOrErrors]; return validationErrors.length > 0 ? validationErrors .filter(({ errorMessage }) => errorMessage) .map(({ errorMessage, errorMessageValues }) => ({ featureKey, ...(scopeKey && { scopeKey }), errorMessage, ...(errorMessageValues && { errorMessageValues }), })) : []; } catch (err) { logger.error( new VError( { name: VERROR_CLUSTER_NAME, cause: err, info: { validator: validatorName, featureKey, ...(scopeKey && { scopeKey }), value, }, }, "error during registered validator" ) ); return [ { featureKey, ...(scopeKey && { scopeKey }), errorMessage: 'registered validator "{0}" failed for value "{1}" with error {2}', errorMessageValues: [validatorName, value, err.message], }, ]; } }) ); return validatorErrors.flat(); } /** * ValidationError must have a user-readable errorMessage. The message can use errorMessageValues, i.e., parameters * which are ignored for localization, but mixed in when the errorMessage is presented to the user. * * @example * const validationErrors = [ * { errorMessage: "got bad value" }, * { errorMessage: 'got bad value with parameter "{0}"', errorMessageValues: [paramFromValue(value)] } * ]; * * @typedef ValidationError * @type object * @property {string} featureKey feature toggle * @property {string} errorMessage user-readable error message * @property {Array<string>} [errorMessageValues] optional parameters for error message, which are ignored for * localization */ /** * Validate the value of a given featureKey, value pair. Allows passing an optional scopeMap that is added to * validationErrors for reference. * * @param {string} featureKey feature key * @param {string|number|boolean|null} value intended value * @param {Object} [scopeMap] optional scope restrictions * @returns {Promise<Array<ValidationError>>} validation errors if any are found or an empty array otherwise */ async validateFeatureValue(featureKey, value, scopeMap = undefined) { return scopeMap === undefined ? await this._validateFeatureValue(featureKey, value) : await this._validateFeatureValue(featureKey, value, { scopeMap, scopeKey: FeatureToggles.getScopeKey(scopeMap), }); } /** * Validate the fallback values. This will only return an array of validation errors, but not an object with * validated values, because fallback values are used even when they are invalid. */ async _validateFallbackValues(fallbackValues) { let validationErrors = []; if (!isObject(fallbackValues)) { return validationErrors; } for (const [featureKey, value] of Object.entries(fallbackValues)) { const entryValidationErrors = await this._validateFeatureValue(featureKey, value); if (Array.isArray(entryValidationErrors) && entryValidationErrors.length > 0) { validationErrors = validationErrors.concat(entryValidationErrors); } } return validationErrors; } async _validateScopedValues(featureKey, scopedValues) { let validationErrors = []; let validatedStateScopedValues = {}; for (const [scopeKey, value] of Object.entries(scopedValues)) { const entryValidationErrors = await this._validateFeatureValue(featureKey, value, { scopeMap: FeatureToggles.getScopeMap(scopeKey), scopeKey, }); let updateValue = value; if (Array.isArray(entryValidationErrors) && entryValidationErrors.length > 0) { validationErrors = validationErrors.concat(entryValidationErrors); updateValue = null; } FeatureToggles._updateStateScopedValuesOneScopeInPlace( validatedStateScopedValues, featureKey, updateValue, scopeKey ); } const validatedScopedValues = Object.prototype.hasOwnProperty.call(validatedStateScopedValues, featureKey) ? validatedStateScopedValues[featureKey] : null; return [validatedScopedValues, validationErrors]; } // ======================================== // END OF VALIDATION SECTION // ======================================== // ======================================== // START OF INITIALIZE SECTION // ======================================== _isKeyActive(featureKey) { return ( this.__config[featureKey][CONFIG_KEY.ACTIVE] !== false && this.__config[featureKey][CONFIG_KEY.APP_URL_ACTIVE] !== false ); } async _freshStateScopedValues() { return await this.__featureKeys.reduce( async (acc, featureKey) => { let [validatedStateScopedValues, validationErrors] = await acc; if (this._isKeyActive(featureKey)) { const validatedScopedValues = await redis.watchedHashGetSetObject( this.__redisKey, featureKey, async (scopedValues) => { if (!isObject(scopedValues)) { return null; } const [validatedScopedValues, scopedValidationErrors] = await this._validateScopedValues( featureKey, scopedValues ); validationErrors = validationErrors.concat(scopedValidationErrors); return validatedScopedValues; } ); FeatureToggles._updateStateScopedValuesAllScopesInPlace( validatedStateScopedValues, featureKey, validatedScopedValues ); } return [validatedStateScopedValues, validationErrors]; }, Promise.resolve([{}, []]) ); } async _migrateStringTypeState(stringTypeStateEntries) { let migrationCount = 0; for (const [featureKey, value] of stringTypeStateEntries) { if ( !FeatureToggles._isValidFeatureKey(this.__config, featureKey) || this.__fallbackValues[featureKey] === value ) { continue; } try { const newRedisStateCallback = (scopedValues) => FeatureToggles._updateScopedValuesInPlace(scopedValues, value, SCOPE_ROOT_KEY); await redis.watchedHashGetSetObject(this.__redisKey, featureKey, newRedisStateCallback); migrationCount++; } catch (err) { logger.error( new VError( { name: VERROR_CLUSTER_NAME, cause: err, info: { featureKey, value, }, }, "error during string type state migration" ) ); } } return migrationCount; } static async readConfigFromFile(configFilepath = DEFAULT_CONFIG_FILEPATH) { const fileData = await readFileAsync(configFilepath); if (/\.ya?ml$/i.test(configFilepath)) { return yaml.parse(fileData.toString()); } if (/\.json$/i.test(configFilepath)) { return JSON.parse(fileData.toString()); } throw new VError( { name: VERROR_CLUSTER_NAME, info: { configFilepath }, }, "config filepath with unsupported extension, allowed extensions are .yaml and .json" ); } static async _consolidatedConfigFilepaths(configFilepath, configFilepaths) { let result = []; if (configFilepath) { result.push(configFilepath); } if (configFilepaths) { result = result.concat(Object.values(configFilepaths)); } if (result.length === 0 && (await tryPathReadable(DEFAULT_CONFIG_FILEPATH))) { result.push(DEFAULT_CONFIG_FILEPATH); } return result; } /** * Implementation for {@link initializeFeatures}. * * @param {InitializeOptions} [options] */ async _initializeFeatures({ configAuto, configFile: configFilepath, configFiles: configFilepaths, config: configRuntime, customRedisCredentials, customRedisClientOptions, } = {}) { if (this.__isInitialized) { return; } const consolidatedConfigFilepaths = await FeatureToggles._consolidatedConfigFilepaths( configFilepath, configFilepaths ); const configFromFilesEntries = await Promise.all( consolidatedConfigFilepaths.map(async (configFilepath) => { try { return [configFilepath, await FeatureToggles.readConfigFromFile(configFilepath)]; } catch (err) { throw new VError( { name: VERROR_CLUSTER_NAME, cause: err, info: { configFilepath, }, }, "initialization aborted, could not read config file" ); } }) ); let toggleCounts; try { toggleCounts = this._processConfig({ configAuto, configFromFilesEntries, configRuntime, }); } catch (err) { throw new VError( { name: VERROR_CLUSTER_NAME, cause: err, }, "initialization aborted, could not process configuration" ); } const validationErrors = await this._validateFallbackValues(this.__fallbackValues); if (Array.isArray(validationErrors) && validationErrors.length > 0) { logger.warning( new VError( { name: VERROR_CLUSTER_NAME, info: { validationErrors: JSON.stringify(validationErrors) }, }, "found invalid fallback values during initialization" ) ); } redis.setCustomOptions(customRedisCredentials, customRedisClientOptions); const redisIntegrationMode = await redis.getIntegrationMode(); if (redisIntegrationMode !== REDIS_INTEGRATION_MODE.NO_REDIS) { try { // NOTE: in our legacy code the redisKey was a string let stringTypeStateEntries; const featureKeyType = await redis.type(this.__redisKey); if (featureKeyType === "string") { const stringTypeState = await redis.getObject(this.__redisKey); if (stringTypeState) { stringTypeStateEntries = Object.entries(stringTypeState); logger.info("found %i string type state entries", stringTypeStateEntries.length); } } if (featureKeyType !== "hash" && featureKeyType !== "none") { await redis.del(this.__redisKey); logger.info("removed legacy redis key of type: %s", featureKeyType); } if (stringTypeStateEntries) { // NOTE: this will write to the redisKey as a hash, so it needs to run after delete const migrationCount = await this._migrateStringTypeState(stringTypeStateEntries); logger.info("migrated %i string type state entries", migrationCount); } const [validatedStateScopedValues, validationErrors] = await this._freshStateScopedValues(); if (Array.isArray(validationErrors) && validationErrors.length > 0) { logger.warning( new VError( { name: VERROR_CLUSTER_NAME, info: { validationErrors: JSON.stringify(validationErrors) }, }, "removed invalid entries from redis during initialization" ) ); } this.__stateScopedValues = validatedStateScopedValues; redis.registerMessageHandler(this.__redisChannel, this.__messageHandler); await redis.subscribe(this.__redisChannel); } catch (err) { logger.warning( cfEnv.isOnCf ? new VError( { name: VERROR_CLUSTER_NAME, cause: err, }, "error during initialization, using fallback values" ) : "error during initialization, using fallback values" ); } } const totalCount = toggleCounts[CONFIG_SOURCE.AUTO] + toggleCounts[CONFIG_SOURCE.FILE] + toggleCounts[CONFIG_SOURCE.RUNTIME]; logger.info( [ "finished initialization", ...(this.__uniqueName ? [`of "${this.__uniqueName}"`] : []), util.format( "with %i feature toggles (%i auto, %i file, %i runtime)", totalCount, toggleCounts[CONFIG_SOURCE.AUTO], toggleCounts[CONFIG_SOURCE.FILE], toggleCounts[CONFIG_SOURCE.RUNTIME] ), `using ${redisIntegrationMode}`, ].join(" ") ); this.__isInitialized = true; return this; } /** * TODO * @typedef Config * @type object */ /** * @typedef InitializeOptions * @type object * @property {Config} [config] * @property {string} [configFile] * @property {string} [configFiles] * @property {Config} [configAuto] * @property {object} [customRedisCredentials] * @property {object} [customRedisClientOptions] */ /** * Initialize needs to run and finish before other APIs are called. It processes the configuration, sets up * related internal state, and starts communication with redis. * * @param {InitializeOptions} [options] */ async initializeFeatures(options) { if (this.__initializePromise) { throw new VError({ name: VERROR_CLUSTER_NAME }, "already initialized"); } this.__initializePromise = this._initializeFeatures(options); return await this.__initializePromise; } get canInitialize() { return !this.__initializePromise; } // ======================================== // END OF INITIALIZE SECTION // ======================================== // ======================================== // START OF GET_FEATURES_INFOS SECTION // ======================================== _getFeatureInfo(featureKey, { stateScopedValues = this.__stateScopedValues } = {}) { let rootValue; let foundScopedValues = false; let scopedValuesInfo; if (stateScopedValues[featureKey]) { scopedValuesInfo = Object.entries(stateScopedValues[featureKey]).reduce((acc, [scopeKey, value]) => { if (scopeKey === SCOPE_ROOT_KEY) { rootValue = value; } else { foundScopedValues = true; acc[scopeKey] = value; } return acc; }, {}); } const isConfigured = this.__config[featureKey]; const configInfo = isConfigured ? Object.entries(this.__config[featureKey]).reduce((acc, [configKey, value]) => { if (CONFIG_INFO_KEY[configKey]) { acc[configKey] = value; } return acc; }, {}) : { [CONFIG_KEY.SOURCE]: CONFIG_SOURCE.NONE }; return { ...(isConfigured && { fallbackValue: this.__fallbackValues[featureKey] }), ...(rootValue !== undefined && { rootValue }), ...(foundScopedValues && { scopedValues: scopedValuesInfo }), config: configInfo, }; } /** * Get feature info for specific featureKey. */ getFeatureInfo(featureKey) { this._ensureInitialized(); if (!FeatureToggles._isValidFeatureKey(this.__config, featureKey)) { return null; } return this._getFeatureInfo(featureKey); } /** * Get server-local feature infos for all configured keys. */ getFeaturesInfos() { this._ensureInitialized(); return this.__featureKeys.reduce((acc, featureKey) => { acc[featureKey] = this._getFeatureInfo(featureKey); return acc; }, {}); } /** * Get remote feature infos for all keys that exist in the redis hash entry, including keys that are not configured. */ async getRemoteFeaturesInfos() { this._ensureInitialized(); let remoteStateScopedValues; // NOTE: for NO_REDIS mode, we show local updates if ((await redis.getIntegrationMode()) === REDIS_INTEGRATION_MODE.NO_REDIS) { remoteStateScopedValues = this.__stateScopedValues; } else { remoteStateScopedValues = await redis.hashGetAllObjects(this.__redisKey); } if (!remoteStateScopedValues) { return null; } return Object.keys(remoteStateScopedValues).reduce((acc, key) => { acc[key] = this._getFeatureInfo(key, { stateScopedValues: remoteStateScopedValues }); return acc; }, {}); } // ======================================== // END OF GET_FEATURES_INFOS SECTION // ======================================== // ======================================== // START OF GET_FEATURES_KEYS SECTION // ======================================== /** * Get the names of all configured feature keys. * * @returns {Array<string>} */ getFeaturesKeys() { this._ensureInitialized(); return this.__featureKeys.slice(); } // ======================================== // END OF GET_FEATURES_KEYS SECTION // ======================================== // ======================================== // START OF GET_FEATURE_VALUE SECTION // ======================================== /** * This is used to make sure scopeMap is either undefined or a shallow map with string entries. This happens for all * public interfaces with a scopeMap parameter, except {@link validateFeatureValue} and {@link changeFeatureValue}. * For these two interfaces, we want the "bad" scopeMaps to cause validation errors. * Also, not for {@link getScopeKey}, where the sanitization must not happen in place. */ static _sanitizeScopeMap(scopeMap) { if (!isObject(scopeMap)) { return undefined; } for (const [scope, value] of Object.entries(scopeMap)) { if (!FeatureToggles._isValidScopeMapValue(value)) { Reflect.deleteProperty(scopeMap, scope); } } return scopeMap; } // NOTE: getScopeMap does the scopeMap sanitization on the fly, because it must not modify scopeMap in place. static getScopeKey(scopeMap) { if (!isObject(scopeMap)) { return SCOPE_ROOT_KEY; } const scopeMapKeys = Object.keys(scopeMap).filter((scope) => FeatureToggles._isValidScopeMapValue(scopeMap[scope])); if (scopeMapKeys.length === 0) { return SCOPE_ROOT_KEY; } return FeatureToggles._getNonRootScopeKey(scopeMap, scopeMapKeys.sort()); } static _getNonRootScopeKey(scopeMap, sortedKeys) { return sortedKeys .map((scopeInnerKey) => scopeInnerKey + SCOPE_KEY_INNER_SEPARATOR + scopeMap[scopeInnerKey]) .join(SCOPE_KEY_OUTER_SEPARATOR); } // NOTE: there are multiple scopeMaps for every scopeKey with more than one inner entry. This will return the unique // scopeMap whose keys are sorted, i.e., matching the keys in the scopeKey. static getScopeMap(scopeKey) { return !this._isValidScopeKey(scopeKey) || scopeKey === undefined || scopeKey === SCOPE_ROOT_KEY ? undefined : scopeKey.split(SCOPE_KEY_OUTER_SEPARATOR).reduce((acc, scopeInnerEntry) => { const [scopeInnerKey, value] = scopeInnerEntry.split(SCOPE_KEY_INNER_SEPARATOR); acc[scopeInnerKey] = value; return acc; }, {}); } // NOTE: this does not return the scope root key, which is a super scope of every scope, because we handle this case // separately in _getFeatureValueForScopeAndStateAndFallback static _getNonRootSuperScopeKeys(superScopeCache, scopeMap) { const scopeMapKeys = Object.keys(scopeMap); const n = scopeMapKeys.length - 1; if (n === -1) { return []; } if (n > SCOPE_PREFERENCE_ORDER_MASKS.length) { logger.error( new VError( { name: VERROR_CLUSTER_NAME, info: { scopeMap: JSON.stringify(scopeMap), maxKeys: SCOPE_PREFERENCE_ORDER_MASKS.length + 1, }, }, "scope exceeds allowed number of keys" ) ); return []; } const scopeKey = FeatureToggles._getNonRootScopeKey(scopeMap, scopeMapKeys.slice().sort()); if (n === 0) { return [scopeKey]; } // NOTE: it's tempting to take the scopeKey as cacheKey here. The problem is that we want to allow the order of the // scopeMap keys to determine the superScopeKeys ordering (see tests). This means we cannot cache with scopeKey, // because it is stable for all scopeMap key orderings. const cacheKey = JSON.stringify(scopeMap); return superScopeCache.getSetCb(cacheKey, () => { const result = [scopeKey]; for (const selectMask of SCOPE_PREFERENCE_ORDER_MASKS[n - 1]) { const selectedKeys = scopeMapKeys.filter((_, keyIndex) => selectMask & (1 << (n - keyIndex))); result.push(FeatureToggles._getNonRootScopeKey(scopeMap, selectedKeys.sort())); } return result; }); } static _getFeatureValueForScopeAndStateAndFallback( superScopeCache, stateScopedValues, fallbackValues, featureKey, scopeMap = undefined ) { const scopedValues = stateScopedValues[featureKey]; const fallbackValue = fallbackValues[featureKey] ?? null; if (scopedValues === undefined) { return fallbackValue; } const scopeRootValue = scopedValues[SCOPE_ROOT_KEY] ?? fallbackValue; if (scopeMap === undefined) { return scopeRootValue; } for (const superScopeKey of FeatureToggles._getNonRootSuperScopeKeys(superScopeCache, scopeMap)) { const scopedValue = scopedValues[superScopeKey]; if (scopedValue !== undefined) { return scopedValue; } } return scopeRootValue; } /** * Get the value of a given feature key or null. * * Usage: * const FEATURE_VALUE_KEY = "/server/part_x/feature_y" * ... * const result = getFeatureValue(FEATURE_VALUE_KEY); * const resultForTenant = getFeatureValue(FEATURE_VALUE_KEY, { tenant: "tenant123" }); * * @param {string} featureKey valid feature key * @param {Object} [scopeMap] optional scope restrictions * @returns {string|number|boolean|null} */ getFeatureValue(featureKey, scopeMap = undefined) { this._ensureInitialized(); scopeMap = FeatureToggles._sanitizeScopeMap(scopeMap); return FeatureToggles._getFeatureValueForScopeAndStateAndFallback( this.__superScopeCache, this.__stateScopedValues, this.__fallbackValues, featureKey, scopeMap ); } // ======================================== // END OF GET_FEATURE_VALUE SECTION // ======================================== // ======================================== // START OF CHANGE_FEATURE_VALUE SECTION // ======================================== // NOTE: this function is modifying in place and also needs the caller to assign the return value to cover the // deletion case. should be used sparingly, prefer _updateStateScopedValues* static _updateScopedValuesInPlace( scopedValues, newValue, scopeKey = SCOPE_ROOT_KEY, { clearSubScopes = false } = {} ) { // NOTE: if there are no existing scoped values, or we want to delete everything but the root key, than the // response is trivial. if (!scopedValues || (clearSubScopes && scopeKey === SCOPE_ROOT_KEY)) { if (newValue !== null) { return { [scopeKey]: newValue }; } else { return null; } } if (clearSubScopes) { // NOTE: we use here, that the scopeKey !== SCOPE_ROOT_KEY const scopeKeyInnerPairs = scopeKey.split(SCOPE_KEY_OUTER_SEPARATOR); const subScopeKeys = Object.keys(scopedValues).filter((someScopeKey) => scopeKeyInnerPairs.every((scopeKeyInnerPair) => someScopeKey.includes(scopeKeyInnerPair)) ); for (const subScopeKey of subScopeKeys) { Reflect.deleteProperty(scopedValues, subScopeKey); } } if (newValue !== null) { scopedValues[scopeKey] = newValue; } else { Reflect.deleteProperty(scopedValues, scopeKey); if (Object.keys(scopedValues).length === 0) { return null; } } return scopedValues; } // NOTE: stateScopedValues needs to be at least an empty object {} static _updateStateScopedValuesAllScopesInPlace(stateScopedValues, featureKey, scopedValues) { if (scopedValues !== null) { stateScopedValues[featureKey] = scopedValues; } else { Reflect.deleteProperty(stateScopedValues, featureKey); } } // NOTE: stateScopedValues needs to be at least an empty object {} static _updateStateScopedValuesOneScopeInPlace(stateScopedValues, featureKey, newValue, scopeKey, options) { const scopedValues = FeatureToggles._updateScopedValuesInPlace( stateScopedValues[featureKey], newValue, scopeKey, options ); FeatureToggles._updateStateScopedValuesAllScopesInPlace(stateScopedValues, featureKey, scopedValues); } /** * ChangeOptions are extra options for the change of a feature toggle. * * Example: * { clearSubScopes: true } * * @typedef ChangeOptions * @type object * @property {boolean} [clearSubScopes] switch to clear all sub scopes, defaults to false * @property {boolean} [remoteOnly] switch to skip all server-local processing to change toggles that are not * configured, defaults to false */ /** * ChangeEntry represents a single value change related to a feature key and an optional scopeMap. Setting newValue * to null means delete the value. Omitting the scopeMap changes the root scope. * * @example * const FEATURE_VALUE_KEY = "/server/part_x/feature_y"; * const entries = [ * { featureKey: FEATURE_VALUE_KEY, newValue: true }, * { featureKey: FEATURE_VALUE_KEY, newValue: true, scopeMap: { tenant: "t1" } }, * { featureKey: FEATURE_VALUE_KEY, newValue: null, options: { clearSubScopes: true } } * ]; * * @typedef ChangeEntry * @type object * @property {string} featureKey feature key * @property {string|number|boolean|null} newValue feature value after change * @property {Map<string, string>} [scopeMap] optional scope tags to where the change applies * @property {ChangeOptions} [options] optional change options */ /** * @param {Array<ChangeEntry>} entries */ static _serializeChangesToRefreshMessage(entries) { return JSON.stringify(entries); } /** * @returns {Array<ChangeEntry>} */ static _deserializeChangesFromRefreshMessage(message) { return tryJsonParse(message); } /** * Refresh local feature values from redis. This will only refresh the local state and not trigger change handlers. */ // NOTE: refresh used to trigger the change handlers, but with scoping keeping this feature would become really messy. // From the state difference, there is no good way to infer the actual scopeMap and options that were used. You would // also have to trigger changes for any small scope-level change leading to lots of callbacks. async refreshFeatureValues() { this._ensureInitialized(); if ((await redis.getIntegrationMode()) === REDIS_INTEGRATION_MODE.NO_REDIS) { return; } try { const [validatedStateScopedValues, validationErrors] = await this._freshStateScopedValues(); if (Array.isArray(validationErrors) && validationErrors.length > 0) { logger.warning( new VError( { name: VERROR_CLUSTER_NAME, info: { validationErrors: JSON.stringify(validationErrors) }, }, "removed invalid entries from redis during refresh" ) ); } this.__stateScopedValues = validatedStateScopedValues; } catch (err) { logger.error(new VError({ name: VERROR_CLUSTER_NAME, cause: err }, "error during refresh feature values")); } } async _triggerChangeHandlers(featureKey, oldValue, newValue, scopeMap, options) { if (oldValue === newValue) { return; } const changeHandlers = this.__featureValueChangeHandlers.getHandlers(featureKey); if (changeHandlers.length === 0) { return; } await Promise.all( changeHandlers.map(async (changeHandler) => { try { return await changeHandler(newValue, oldValue, scopeMap, options); } catch (err) { logger.error( new VError( { name: VERROR_CLUSTER_NAME, cause: err, info: { changeHandler: changeHandler.name || "anonymous", featureKey, }, }, "error during feature value change handler" ) ); } }) ); } /** * Handler for message with change entries. */ async _messageHandler(message) { const changeEntries = FeatureToggles._deserializeChangesFromRefreshMessage(message); if (!Array.isArray(changeEntries)) { logger.error( new VError( { name: VERROR_CLUSTER_NAME, info: { channel: this.__redisChannel, message, }, }, "error during message deserialization" ) ); return; } await Promise.all( changeEntries.map(async (changeEntry) => { try { if (!isObject(changeEntry) || changeEntry.featureKey === undefined || changeEntry.newValue === undefined) { logger.warning( new VError( { name: VERROR_CLUSTER_NAME, info: { changeEntry: JSON.stringify(changeEntry), }, }, "received and ignored change entry" ) ); return; } const { featureKey, newValue, scopeMap, options } = changeEntry; const scopeKey = FeatureToggles.getScopeKey(scopeMap); const oldValue = FeatureToggles._getFeatureValueForScopeAndStateAndFallback( this.__superScopeCache, this.__stateScopedValues, this.__fallbackValues, featureKey, scopeMap ); const validationErrors = await this._validateFeatureValue(featureKey, newValue, { scopeMap, scopeKey, isChange: true, }); if (Array.isArray(validationErrors) && validationErrors.length > 0) { logger.warning( new VError( { name: VERROR_CLUSTER_NAME, info: { validationErrors: JSON.stringify(validationErrors), }, }, "received and ignored invalid value from message" ) ); return; } FeatureToggles._updateStateScopedValuesOneScopeInPlace( this.__stateScopedValues, featureKey, newValue, scopeKey, options );