diff --git a/CHANGES.txt b/CHANGES.txt index 8f6e73d9..6da0984c 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,4 +1,5 @@ 2.8.0 (October 28, 2025) + - Added new configuration for Fallback Treatments, which allows setting a treatment value and optional config to be returned in place of "control", either globally or by flag. Read more in our docs. - Added `client.getStatus()` method to retrieve the client readiness status properties (`isReady`, `isReadyFromCache`, etc). - Added `client.whenReady()` and `client.whenReadyFromCache()` methods to replace the deprecated `client.ready()` method, which has an issue causing the returned promise to hang when using async/await syntax if it was rejected. - Updated the SDK_READY_FROM_CACHE event to be emitted alongside the SDK_READY event if it hasn’t already been emitted. diff --git a/package-lock.json b/package-lock.json index 356a4e93..a6093d8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@splitsoftware/splitio-commons", - "version": "2.7.1", + "version": "2.8.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@splitsoftware/splitio-commons", - "version": "2.7.1", + "version": "2.8.0", "license": "Apache-2.0", "dependencies": { "@types/ioredis": "^4.28.0", diff --git a/package.json b/package.json index 30ea1784..ab0a1d2f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@splitsoftware/splitio-commons", - "version": "2.7.1", + "version": "2.8.0", "description": "Split JavaScript SDK common components", "main": "cjs/index.js", "module": "esm/index.js", diff --git a/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts new file mode 100644 index 00000000..6240e2a0 --- /dev/null +++ b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts @@ -0,0 +1,134 @@ +import { FallbackTreatmentsCalculator } from '../'; +import type { FallbackTreatmentConfiguration } from '../../../../types/splitio'; +import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock'; +import { CONTROL } from '../../../utils/constants'; + +describe('FallbackTreatmentsCalculator' , () => { + const longName = 'a'.repeat(101); + + test('logs an error if flag name is invalid - by Flag', () => { + let config: FallbackTreatmentConfiguration = { + byFlag: { + 'feature A': { treatment: 'TREATMENT_A', config: '{ value: 1 }' }, + }, + }; + new FallbackTreatmentsCalculator(loggerMock, config); + expect(loggerMock.error.mock.calls[0][0]).toBe( + 'Fallback treatments - Discarded flag \'feature A\': Invalid flag name (max 100 chars, no spaces)' + ); + config = { + byFlag: { + [longName]: { treatment: 'TREATMENT_A', config: '{ value: 1 }' }, + }, + }; + new FallbackTreatmentsCalculator(loggerMock, config); + expect(loggerMock.error.mock.calls[1][0]).toBe( + `Fallback treatments - Discarded flag '${longName}': Invalid flag name (max 100 chars, no spaces)` + ); + + config = { + byFlag: { + 'featureB': { treatment: longName, config: '{ value: 1 }' }, + }, + }; + new FallbackTreatmentsCalculator(loggerMock, config); + expect(loggerMock.error.mock.calls[2][0]).toBe( + 'Fallback treatments - Discarded treatment for flag \'featureB\': Invalid treatment (max 100 chars and must match pattern)' + ); + + config = { + byFlag: { + // @ts-ignore + 'featureC': { config: '{ global: true }' }, + }, + }; + new FallbackTreatmentsCalculator(loggerMock, config); + expect(loggerMock.error.mock.calls[3][0]).toBe( + 'Fallback treatments - Discarded treatment for flag \'featureC\': Invalid treatment (max 100 chars and must match pattern)' + ); + + config = { + byFlag: { + // @ts-ignore + 'featureC': { treatment: 'invalid treatment!', config: '{ global: true }' }, + }, + }; + new FallbackTreatmentsCalculator(loggerMock, config); + expect(loggerMock.error.mock.calls[4][0]).toBe( + 'Fallback treatments - Discarded treatment for flag \'featureC\': Invalid treatment (max 100 chars and must match pattern)' + ); + }); + + test('logs an error if flag name is invalid - global', () => { + let config: FallbackTreatmentConfiguration = { + global: { treatment: longName, config: '{ value: 1 }' }, + }; + new FallbackTreatmentsCalculator(loggerMock, config); + expect(loggerMock.error.mock.calls[2][0]).toBe( + 'Fallback treatments - Discarded treatment for flag \'featureB\': Invalid treatment (max 100 chars and must match pattern)' + ); + + config = { + // @ts-ignore + global: { config: '{ global: true }' }, + }; + new FallbackTreatmentsCalculator(loggerMock, config); + expect(loggerMock.error.mock.calls[3][0]).toBe( + 'Fallback treatments - Discarded treatment for flag \'featureC\': Invalid treatment (max 100 chars and must match pattern)' + ); + + config = { + // @ts-ignore + global: { treatment: 'invalid treatment!', config: '{ global: true }' }, + }; + new FallbackTreatmentsCalculator(loggerMock, config); + expect(loggerMock.error.mock.calls[4][0]).toBe( + 'Fallback treatments - Discarded treatment for flag \'featureC\': Invalid treatment (max 100 chars and must match pattern)' + ); + }); + + test('returns specific fallback if flag exists', () => { + const config: FallbackTreatmentConfiguration = { + byFlag: { + 'featureA': { treatment: 'TREATMENT_A', config: '{ value: 1 }' }, + }, + }; + const calculator = new FallbackTreatmentsCalculator(loggerMock, config); + const result = calculator.resolve('featureA', 'label by flag'); + + expect(result).toEqual({ + treatment: 'TREATMENT_A', + config: '{ value: 1 }', + label: 'fallback - label by flag', + }); + }); + + test('returns global fallback if flag is missing and global exists', () => { + const config: FallbackTreatmentConfiguration = { + byFlag: {}, + global: { treatment: 'GLOBAL_TREATMENT', config: '{ global: true }' }, + }; + const calculator = new FallbackTreatmentsCalculator(loggerMock, config); + const result = calculator.resolve('missingFlag', 'label by global'); + + expect(result).toEqual({ + treatment: 'GLOBAL_TREATMENT', + config: '{ global: true }', + label: 'fallback - label by global', + }); + }); + + test('returns control fallback if flag and global are missing', () => { + const config: FallbackTreatmentConfiguration = { + byFlag: {}, + }; + const calculator = new FallbackTreatmentsCalculator(loggerMock, config); + const result = calculator.resolve('missingFlag', 'label by noFallback'); + + expect(result).toEqual({ + treatment: CONTROL, + config: null, + label: 'label by noFallback', + }); + }); +}); diff --git a/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts new file mode 100644 index 00000000..aaaf106c --- /dev/null +++ b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts @@ -0,0 +1,104 @@ +import { FallbacksSanitizer } from '../fallbackSanitizer'; +import { TreatmentWithConfig } from '../../../../types/splitio'; +import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock'; + +describe('FallbacksSanitizer', () => { + const validTreatment: TreatmentWithConfig = { treatment: 'on', config: '{"color":"blue"}' }; + const invalidTreatment: TreatmentWithConfig = { treatment: ' ', config: null }; + + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + (loggerMock.error as jest.Mock).mockRestore(); + }); + + describe('isValidFlagName', () => { + test('returns true for a valid flag name', () => { + // @ts-expect-private-access + expect((FallbacksSanitizer as any).isValidFlagName('my_flag')).toBe(true); + }); + + test('returns false for a name longer than 100 chars', () => { + const longName = 'a'.repeat(101); + expect((FallbacksSanitizer as any).isValidFlagName(longName)).toBe(false); + }); + + test('returns false if the name contains spaces', () => { + expect((FallbacksSanitizer as any).isValidFlagName('invalid flag')).toBe(false); + }); + }); + + describe('isValidTreatment', () => { + test('returns true for a valid treatment string', () => { + expect((FallbacksSanitizer as any).isValidTreatment(validTreatment)).toBe(true); + }); + + test('returns false for null or undefined', () => { + expect((FallbacksSanitizer as any).isValidTreatment(null)).toBe(false); + expect((FallbacksSanitizer as any).isValidTreatment(undefined)).toBe(false); + }); + + test('returns false for a treatment longer than 100 chars', () => { + const long = { treatment: 'a'.repeat(101) }; + expect((FallbacksSanitizer as any).isValidTreatment(long)).toBe(false); + }); + + test('returns false if treatment does not match regex pattern', () => { + const invalid = { treatment: 'invalid treatment!' }; + expect((FallbacksSanitizer as any).isValidTreatment(invalid)).toBe(false); + }); + }); + + describe('sanitizeGlobal', () => { + test('returns the treatment if valid', () => { + expect(FallbacksSanitizer.sanitizeGlobal(loggerMock, validTreatment)).toEqual(validTreatment); + expect(loggerMock.error).not.toHaveBeenCalled(); + }); + + test('returns undefined and logs error if invalid', () => { + const result = FallbacksSanitizer.sanitizeGlobal(loggerMock, invalidTreatment); + expect(result).toBeUndefined(); + expect(loggerMock.error).toHaveBeenCalledWith( + expect.stringContaining('Fallback treatments - Discarded fallback') + ); + }); + }); + + describe('sanitizeByFlag', () => { + test('returns a sanitized map with valid entries only', () => { + const input = { + valid_flag: validTreatment, + 'invalid flag': validTreatment, + bad_treatment: invalidTreatment, + }; + + const result = FallbacksSanitizer.sanitizeByFlag(loggerMock, input); + + expect(result).toEqual({ valid_flag: validTreatment }); + expect(loggerMock.error).toHaveBeenCalledTimes(2); // invalid flag + bad_treatment + }); + + test('returns empty object if all invalid', () => { + const input = { + 'invalid flag': invalidTreatment, + }; + + const result = FallbacksSanitizer.sanitizeByFlag(loggerMock, input); + expect(result).toEqual({}); + expect(loggerMock.error).toHaveBeenCalled(); + }); + + test('returns same object if all valid', () => { + const input = { + flag_one: validTreatment, + flag_two: { treatment: 'valid_2', config: null }, + }; + + const result = FallbacksSanitizer.sanitizeByFlag(loggerMock, input); + expect(result).toEqual(input); + expect(loggerMock.error).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/evaluator/fallbackTreatmentsCalculator/constants.ts b/src/evaluator/fallbackTreatmentsCalculator/constants.ts new file mode 100644 index 00000000..292bae0f --- /dev/null +++ b/src/evaluator/fallbackTreatmentsCalculator/constants.ts @@ -0,0 +1,4 @@ +export enum FallbackDiscardReason { + FlagName = 'Invalid flag name (max 100 chars, no spaces)', + Treatment = 'Invalid treatment (max 100 chars and must match pattern)', +} diff --git a/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts b/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts new file mode 100644 index 00000000..66ec65f3 --- /dev/null +++ b/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts @@ -0,0 +1,62 @@ +import { Treatment, TreatmentWithConfig } from '../../../../types/splitio'; +import { ILogger } from '../../../logger/types'; +import { isObject, isString } from '../../../utils/lang'; +import { FallbackDiscardReason } from '../constants'; + + +export class FallbacksSanitizer { + + private static readonly pattern = /^[0-9]+[.a-zA-Z0-9_-]*$|^[a-zA-Z]+[a-zA-Z0-9_-]*$/; + + private static isValidFlagName(name: string): boolean { + return name.length <= 100 && !name.includes(' '); + } + + private static isValidTreatment(t?: Treatment | TreatmentWithConfig): boolean { + const treatment = isObject(t) ? (t as TreatmentWithConfig).treatment : t; + + if (!isString(treatment) || treatment.length > 100) { + return false; + } + return FallbacksSanitizer.pattern.test(treatment); + } + + static sanitizeGlobal(logger: ILogger, treatment?: Treatment | TreatmentWithConfig): Treatment | TreatmentWithConfig | undefined { + if (!this.isValidTreatment(treatment)) { + logger.error( + `Fallback treatments - Discarded fallback: ${FallbackDiscardReason.Treatment}` + ); + return undefined; + } + return treatment; + } + + static sanitizeByFlag( + logger: ILogger, + byFlagFallbacks: Record + ): Record { + const sanitizedByFlag: Record = {}; + + const entries = Object.keys(byFlagFallbacks); + entries.forEach((flag) => { + const t = byFlagFallbacks[flag]; + if (!this.isValidFlagName(flag)) { + logger.error( + `Fallback treatments - Discarded flag '${flag}': ${FallbackDiscardReason.FlagName}` + ); + return; + } + + if (!this.isValidTreatment(t)) { + logger.error( + `Fallback treatments - Discarded treatment for flag '${flag}': ${FallbackDiscardReason.Treatment}` + ); + return; + } + + sanitizedByFlag[flag] = t; + }); + + return sanitizedByFlag; + } +} diff --git a/src/evaluator/fallbackTreatmentsCalculator/index.ts b/src/evaluator/fallbackTreatmentsCalculator/index.ts new file mode 100644 index 00000000..7921f72c --- /dev/null +++ b/src/evaluator/fallbackTreatmentsCalculator/index.ts @@ -0,0 +1,57 @@ +import { FallbackTreatmentConfiguration, Treatment, TreatmentWithConfig } from '../../../types/splitio'; +import { FallbacksSanitizer } from './fallbackSanitizer'; +import { CONTROL } from '../../utils/constants'; +import { isString } from '../../utils/lang'; +import { ILogger } from '../../logger/types'; + +export type IFallbackTreatmentsCalculator = { + resolve(flagName: string, label: string): TreatmentWithConfig & { label: string }; +} + +export const FALLBACK_PREFIX = 'fallback - '; + +export class FallbackTreatmentsCalculator implements IFallbackTreatmentsCalculator { + private readonly fallbacks: FallbackTreatmentConfiguration; + + constructor(logger: ILogger, fallbacks?: FallbackTreatmentConfiguration) { + const sanitizedGlobal = fallbacks?.global ? FallbacksSanitizer.sanitizeGlobal(logger, fallbacks.global) : undefined; + const sanitizedByFlag = fallbacks?.byFlag ? FallbacksSanitizer.sanitizeByFlag(logger, fallbacks.byFlag) : {}; + this.fallbacks = { + global: sanitizedGlobal, + byFlag: sanitizedByFlag + }; + } + + resolve(flagName: string, label: string): TreatmentWithConfig & { label: string } { + const treatment = this.fallbacks.byFlag?.[flagName]; + if (treatment) { + return this.copyWithLabel(treatment, label); + } + + if (this.fallbacks.global) { + return this.copyWithLabel(this.fallbacks.global, label); + } + + return { + treatment: CONTROL, + config: null, + label, + }; + } + + private copyWithLabel(fallback: Treatment | TreatmentWithConfig, label: string): TreatmentWithConfig & { label: string } { + if (isString(fallback)) { + return { + treatment: fallback, + config: null, + label: `${FALLBACK_PREFIX}${label}`, + }; + } + + return { + treatment: fallback.treatment, + config: fallback.config, + label: `${FALLBACK_PREFIX}${label}`, + }; + } +} diff --git a/src/sdkClient/__tests__/clientInputValidation.spec.ts b/src/sdkClient/__tests__/clientInputValidation.spec.ts index 452949e8..f2f5648f 100644 --- a/src/sdkClient/__tests__/clientInputValidation.spec.ts +++ b/src/sdkClient/__tests__/clientInputValidation.spec.ts @@ -4,6 +4,7 @@ import { clientInputValidationDecorator } from '../clientInputValidation'; // Mocks import { DebugLogger } from '../../logger/browser/DebugLogger'; import { createClientMock } from './testUtils'; +import { FallbackTreatmentsCalculator, IFallbackTreatmentsCalculator } from '../../evaluator/fallbackTreatmentsCalculator'; const settings: any = { log: DebugLogger(), @@ -13,13 +14,15 @@ const settings: any = { const EVALUATION_RESULT = 'on'; const client: any = createClientMock(EVALUATION_RESULT); +const fallbackTreatmentsCalculator: IFallbackTreatmentsCalculator = new FallbackTreatmentsCalculator(settings); + const readinessManager: any = { isReadyFromCache: () => true, isDestroyed: () => false }; describe('clientInputValidationDecorator', () => { - const clientWithValidation = clientInputValidationDecorator(settings, client, readinessManager); + const clientWithValidation = clientInputValidationDecorator(settings, client, readinessManager, fallbackTreatmentsCalculator); const logSpy = jest.spyOn(console, 'log'); beforeEach(() => { diff --git a/src/sdkClient/__tests__/sdkClientMethod.spec.ts b/src/sdkClient/__tests__/sdkClientMethod.spec.ts index e2f53f83..31b62356 100644 --- a/src/sdkClient/__tests__/sdkClientMethod.spec.ts +++ b/src/sdkClient/__tests__/sdkClientMethod.spec.ts @@ -4,6 +4,7 @@ import { sdkClientMethodFactory } from '../sdkClientMethod'; import { assertClientApi } from './testUtils'; import { telemetryTrackerFactory } from '../../trackers/telemetryTracker'; import { IBasicClient } from '../../types'; +import { FallbackTreatmentsCalculator } from '../../evaluator/fallbackTreatmentsCalculator'; const errorMessage = 'Shared Client not supported by the storage mechanism. Create isolated instances instead.'; @@ -17,7 +18,8 @@ const paramMocks = [ settings: { mode: CONSUMER_MODE, log: loggerMock, core: { authorizationKey: 'sdk key '} }, telemetryTracker: telemetryTrackerFactory(), clients: {}, - uniqueKeysTracker: { start: jest.fn(), stop: jest.fn() } + uniqueKeysTracker: { start: jest.fn(), stop: jest.fn() }, + fallbackTreatmentsCalculator: new FallbackTreatmentsCalculator(loggerMock, {}) }, // SyncManager (i.e., Sync SDK) and Signal listener { @@ -28,7 +30,8 @@ const paramMocks = [ settings: { mode: STANDALONE_MODE, log: loggerMock, core: { authorizationKey: 'sdk key '} }, telemetryTracker: telemetryTrackerFactory(), clients: {}, - uniqueKeysTracker: { start: jest.fn(), stop: jest.fn() } + uniqueKeysTracker: { start: jest.fn(), stop: jest.fn() }, + fallbackTreatmentsCalculator: new FallbackTreatmentsCalculator(loggerMock, {}) } ]; diff --git a/src/sdkClient/client.ts b/src/sdkClient/client.ts index 2e431dc8..9a7642e6 100644 --- a/src/sdkClient/client.ts +++ b/src/sdkClient/client.ts @@ -35,7 +35,7 @@ function stringify(options?: SplitIO.EvaluationOptions) { * Creator of base client with getTreatments and track methods. */ export function clientFactory(params: ISdkFactoryContext): SplitIO.IClient | SplitIO.IAsyncClient { - const { sdkReadinessManager: { readinessManager }, storage, settings, impressionsTracker, eventTracker, telemetryTracker } = params; + const { sdkReadinessManager: { readinessManager }, storage, settings, impressionsTracker, eventTracker, telemetryTracker, fallbackTreatmentsCalculator } = params; const { log, mode } = settings; const isAsync = isConsumerMode(mode); @@ -143,7 +143,16 @@ export function clientFactory(params: ISdkFactoryContext): SplitIO.IClient | Spl const matchingKey = getMatching(key); const bucketingKey = getBucketing(key); - const { treatment, label, changeNumber, config = null, impressionsDisabled } = evaluation; + const { changeNumber, impressionsDisabled } = evaluation; + let { treatment, label, config = null } = evaluation; + + if (treatment === CONTROL) { + const fallbackTreatment = fallbackTreatmentsCalculator.resolve(featureFlagName, label); + treatment = fallbackTreatment.treatment; + label = fallbackTreatment.label; + config = fallbackTreatment.config; + } + log.info(IMPRESSION, [featureFlagName, matchingKey, treatment, label]); if (validateSplitExistence(log, readinessManager, featureFlagName, label, invokingMethodName)) { diff --git a/src/sdkClient/clientInputValidation.ts b/src/sdkClient/clientInputValidation.ts index b67025d7..d0083feb 100644 --- a/src/sdkClient/clientInputValidation.ts +++ b/src/sdkClient/clientInputValidation.ts @@ -1,4 +1,3 @@ -import { objectAssign } from '../utils/lang/objectAssign'; import { validateAttributes, validateEvent, @@ -13,19 +12,20 @@ import { validateEvaluationOptions } from '../utils/inputValidation'; import { startsWith } from '../utils/lang'; -import { CONTROL, CONTROL_WITH_CONFIG, GET_TREATMENT, GET_TREATMENTS, GET_TREATMENTS_BY_FLAG_SET, GET_TREATMENTS_BY_FLAG_SETS, GET_TREATMENTS_WITH_CONFIG, GET_TREATMENTS_WITH_CONFIG_BY_FLAG_SET, GET_TREATMENTS_WITH_CONFIG_BY_FLAG_SETS, GET_TREATMENT_WITH_CONFIG, TRACK_FN_LABEL } from '../utils/constants'; +import { GET_TREATMENT, GET_TREATMENTS, GET_TREATMENTS_BY_FLAG_SET, GET_TREATMENTS_BY_FLAG_SETS, GET_TREATMENTS_WITH_CONFIG, GET_TREATMENTS_WITH_CONFIG_BY_FLAG_SET, GET_TREATMENTS_WITH_CONFIG_BY_FLAG_SETS, GET_TREATMENT_WITH_CONFIG, TRACK_FN_LABEL } from '../utils/constants'; import { IReadinessManager } from '../readiness/types'; import { MaybeThenable } from '../dtos/types'; import { ISettings } from '../types'; import SplitIO from '../../types/splitio'; import { isConsumerMode } from '../utils/settingsValidation/mode'; import { validateFlagSets } from '../utils/settingsValidation/splitFilters'; +import { IFallbackTreatmentsCalculator } from '../evaluator/fallbackTreatmentsCalculator'; /** * Decorator that validates the input before actually executing the client methods. * We should "guard" the client here, while not polluting the "real" implementation of those methods. */ -export function clientInputValidationDecorator(settings: ISettings, client: TClient, readinessManager: IReadinessManager): TClient { +export function clientInputValidationDecorator(settings: ISettings, client: TClient, readinessManager: IReadinessManager, fallbackTreatmentsCalculator: IFallbackTreatmentsCalculator): TClient { const { log, mode } = settings; const isAsync = isConsumerMode(mode); @@ -59,6 +59,19 @@ export function clientInputValidationDecorator(value: T): MaybeThenable { return isAsync ? Promise.resolve(value) : value; } @@ -69,7 +82,8 @@ export function clientInputValidationDecorator res[split] = CONTROL); + if (params.nameOrNames) (params.nameOrNames as string[]).forEach((split: string) => res[split] = evaluateFallBackTreatment(split, false) as SplitIO.Treatment); return wrapResult(res); } @@ -103,7 +118,7 @@ export function clientInputValidationDecorator res[split] = objectAssign({}, CONTROL_WITH_CONFIG)); + if (params.nameOrNames) (params.nameOrNames as string[]).forEach(split => res[split] = evaluateFallBackTreatment(split, true) as SplitIO.TreatmentWithConfig); return wrapResult(res); } diff --git a/src/sdkClient/sdkClient.ts b/src/sdkClient/sdkClient.ts index cc44c9f7..01fee12b 100644 --- a/src/sdkClient/sdkClient.ts +++ b/src/sdkClient/sdkClient.ts @@ -43,7 +43,8 @@ export function sdkClientFactory(params: ISdkFactoryContext, isSharedClient?: bo clientInputValidationDecorator( settings, clientFactory(params), - sdkReadinessManager.readinessManager + sdkReadinessManager.readinessManager, + params.fallbackTreatmentsCalculator ), // Sdk destroy diff --git a/src/sdkFactory/__tests__/index.spec.ts b/src/sdkFactory/__tests__/index.spec.ts index e46296be..2165bcd4 100644 --- a/src/sdkFactory/__tests__/index.spec.ts +++ b/src/sdkFactory/__tests__/index.spec.ts @@ -3,6 +3,7 @@ import { sdkFactory } from '../index'; import { fullSettings } from '../../utils/settingsValidation/__tests__/settings.mocks'; import SplitIO from '../../../types/splitio'; import { EventEmitter } from '../../utils/MinEvents'; +import { FallbackTreatmentsCalculator } from '../../evaluator/fallbackTreatmentsCalculator'; /** Mocks */ @@ -36,6 +37,7 @@ const paramsForAsyncSDK = { platform: { EventEmitter }, + fallbackTreatmentsCalculator: new FallbackTreatmentsCalculator(fullSettings.log) }; const SignalListenerInstanceMock = { start: jest.fn() }; diff --git a/src/sdkFactory/index.ts b/src/sdkFactory/index.ts index eba01028..5e38d5a7 100644 --- a/src/sdkFactory/index.ts +++ b/src/sdkFactory/index.ts @@ -17,6 +17,7 @@ import { DEBUG, OPTIMIZED } from '../utils/constants'; import { setRolloutPlan } from '../storages/setRolloutPlan'; import { IStorageSync } from '../storages/types'; import { getMatching } from '../utils/key'; +import { FallbackTreatmentsCalculator } from '../evaluator/fallbackTreatmentsCalculator'; /** * Modular SDK factory @@ -60,6 +61,8 @@ export function sdkFactory(params: ISdkFactoryParams): SplitIO.ISDK | SplitIO.IA } }); + const fallbackTreatmentsCalculator = new FallbackTreatmentsCalculator(settings.log, settings.fallbackTreatments); + if (initialRolloutPlan) { setRolloutPlan(log, initialRolloutPlan, storage as IStorageSync, key && getMatching(key)); if ((storage as IStorageSync).splits.getChangeNumber() > -1) readiness.splits.emit(SDK_SPLITS_CACHE_LOADED); @@ -85,7 +88,7 @@ export function sdkFactory(params: ISdkFactoryParams): SplitIO.ISDK | SplitIO.IA // splitApi is used by SyncManager and Browser signal listener const splitApi = splitApiFactory && splitApiFactory(settings, platform, telemetryTracker); - const ctx: ISdkFactoryContext = { clients, splitApi, eventTracker, impressionsTracker, telemetryTracker, uniqueKeysTracker, sdkReadinessManager, readiness, settings, storage, platform }; + const ctx: ISdkFactoryContext = { clients, splitApi, eventTracker, impressionsTracker, telemetryTracker, uniqueKeysTracker, sdkReadinessManager, readiness, settings, storage, platform, fallbackTreatmentsCalculator }; const syncManager = syncManagerFactory && syncManagerFactory(ctx as ISdkFactoryContextSync); ctx.syncManager = syncManager; diff --git a/src/sdkFactory/types.ts b/src/sdkFactory/types.ts index 25882c38..00890c8e 100644 --- a/src/sdkFactory/types.ts +++ b/src/sdkFactory/types.ts @@ -3,6 +3,7 @@ import { ISignalListener } from '../listeners/types'; import { IReadinessManager, ISdkReadinessManager } from '../readiness/types'; import type { sdkManagerFactory } from '../sdkManager'; import type { splitApiFactory } from '../services/splitApi'; +import type { IFallbackTreatmentsCalculator } from '../evaluator/fallbackTreatmentsCalculator'; import { IFetch, ISplitApi, IEventSourceConstructor } from '../services/types'; import { IStorageAsync, IStorageSync, IStorageFactoryParams } from '../storages/types'; import { ISyncManager } from '../sync/types'; @@ -51,6 +52,7 @@ export interface ISdkFactoryContext { splitApi?: ISplitApi syncManager?: ISyncManager, clients: Record, + fallbackTreatmentsCalculator: IFallbackTreatmentsCalculator } export interface ISdkFactoryContextSync extends ISdkFactoryContext { diff --git a/src/utils/inputValidation/splitExistence.ts b/src/utils/inputValidation/splitExistence.ts index 60ac3743..c8559b2a 100644 --- a/src/utils/inputValidation/splitExistence.ts +++ b/src/utils/inputValidation/splitExistence.ts @@ -1,4 +1,4 @@ -import { SPLIT_NOT_FOUND } from '../labels'; +import { FALLBACK_SPLIT_NOT_FOUND, SPLIT_NOT_FOUND } from '../labels'; import { IReadinessManager } from '../../readiness/types'; import { ILogger } from '../../logger/types'; import { WARN_NOT_EXISTENT_SPLIT } from '../../logger/constants'; @@ -9,7 +9,7 @@ import { WARN_NOT_EXISTENT_SPLIT } from '../../logger/constants'; */ export function validateSplitExistence(log: ILogger, readinessManager: IReadinessManager, splitName: string, labelOrSplitObj: any, method: string): boolean { if (readinessManager.isReady()) { // Only if it's ready (synced with BE) we validate this, otherwise it may just be that the SDK is still syncing - if (labelOrSplitObj === SPLIT_NOT_FOUND || labelOrSplitObj == null) { + if (labelOrSplitObj === SPLIT_NOT_FOUND || labelOrSplitObj === FALLBACK_SPLIT_NOT_FOUND || labelOrSplitObj == null) { log.warn(WARN_NOT_EXISTENT_SPLIT, [method, splitName]); return false; } diff --git a/src/utils/labels/index.ts b/src/utils/labels/index.ts index 957100d7..78117a1d 100644 --- a/src/utils/labels/index.ts +++ b/src/utils/labels/index.ts @@ -1,3 +1,5 @@ +import { FALLBACK_PREFIX } from '../../evaluator/fallbackTreatmentsCalculator'; + export const SPLIT_KILLED = 'killed'; export const NO_CONDITION_MATCH = 'default rule'; export const SPLIT_NOT_FOUND = 'definition not found'; @@ -7,3 +9,4 @@ export const SPLIT_ARCHIVED = 'archived'; export const NOT_IN_SPLIT = 'not in split'; export const UNSUPPORTED_MATCHER_TYPE = 'targeting rule type unsupported by sdk'; export const PREREQUISITES_NOT_MET = 'prerequisites not met'; +export const FALLBACK_SPLIT_NOT_FOUND = FALLBACK_PREFIX + SPLIT_NOT_FOUND; diff --git a/types/splitio.d.ts b/types/splitio.d.ts index 1c9df313..a2903c27 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -618,6 +618,10 @@ declare namespace SplitIO { * User consent status if using in client-side. Undefined if using in server-side (Node.js). */ readonly userConsent?: ConsentStatus; + /** + * Fallback treatments to be used when the SDK is not ready or the flag is not found. + */ + readonly fallbackTreatments?: FallbackTreatmentConfiguration; } /** * Log levels. @@ -1295,6 +1299,15 @@ declare namespace SplitIO { * User consent status. */ type ConsentStatus = 'GRANTED' | 'DECLINED' | 'UNKNOWN'; + /** + * Fallback treatments to be used when the SDK is not ready or the flag is not found. + */ + type FallbackTreatmentConfiguration = { + global?: Treatment | TreatmentWithConfig, + byFlag?: { + [featureFlagName: string]: Treatment | TreatmentWithConfig + } + } /** * Logger. Its interface details are not part of the public API. It shouldn't be used directly. */