diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b4cd7bc..c2128f2b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,8 @@ jobs: - name: Set up nodejs uses: actions/setup-node@v4 with: - node-version: 'lts/*' + # @TODO: rollback to 'lts/*' + node-version: '22' cache: 'npm' - name: npm CI diff --git a/CHANGES.txt b/CHANGES.txt index 6da0984c..4c71ee9f 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,4 +1,4 @@ -2.8.0 (October 28, 2025) +2.8.0 (October 30, 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. diff --git a/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts index 6240e2a0..2461bff6 100644 --- a/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts +++ b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts @@ -1,99 +1,15 @@ 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 calculator = new FallbackTreatmentsCalculator(config); const result = calculator.resolve('featureA', 'label by flag'); expect(result).toEqual({ @@ -108,7 +24,7 @@ describe('FallbackTreatmentsCalculator' , () => { byFlag: {}, global: { treatment: 'GLOBAL_TREATMENT', config: '{ global: true }' }, }; - const calculator = new FallbackTreatmentsCalculator(loggerMock, config); + const calculator = new FallbackTreatmentsCalculator(config); const result = calculator.resolve('missingFlag', 'label by global'); expect(result).toEqual({ @@ -122,7 +38,7 @@ describe('FallbackTreatmentsCalculator' , () => { const config: FallbackTreatmentConfiguration = { byFlag: {}, }; - const calculator = new FallbackTreatmentsCalculator(loggerMock, config); + const calculator = new FallbackTreatmentsCalculator(config); const result = calculator.resolve('missingFlag', 'label by noFallback'); expect(result).toEqual({ diff --git a/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts index aaaf106c..27c0ab7a 100644 --- a/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts +++ b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts @@ -1,65 +1,70 @@ -import { FallbacksSanitizer } from '../fallbackSanitizer'; +import { isValidFlagName, isValidTreatment, sanitizeFallbacks } 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 }; + const fallbackMock = { + global: undefined, + byFlag: {} + }; beforeEach(() => { - jest.spyOn(console, 'error').mockImplementation(() => {}); - }); - - afterEach(() => { - (loggerMock.error as jest.Mock).mockRestore(); + loggerMock.mockClear(); }); describe('isValidFlagName', () => { test('returns true for a valid flag name', () => { // @ts-expect-private-access - expect((FallbacksSanitizer as any).isValidFlagName('my_flag')).toBe(true); + expect(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); + expect(isValidFlagName(longName)).toBe(false); + }); + + test('returns false if the name contains spaces', () => { + expect(isValidFlagName('invalid flag')).toBe(false); }); test('returns false if the name contains spaces', () => { - expect((FallbacksSanitizer as any).isValidFlagName('invalid flag')).toBe(false); + // @ts-ignore + expect(isValidFlagName(true)).toBe(false); }); }); describe('isValidTreatment', () => { test('returns true for a valid treatment string', () => { - expect((FallbacksSanitizer as any).isValidTreatment(validTreatment)).toBe(true); + expect(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); + expect(isValidTreatment()).toBe(false); + expect(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); + const long = { treatment: 'a'.repeat(101), config: null }; + expect(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); + const invalid = { treatment: 'invalid treatment!', config: null }; + expect(isValidTreatment(invalid)).toBe(false); }); }); describe('sanitizeGlobal', () => { test('returns the treatment if valid', () => { - expect(FallbacksSanitizer.sanitizeGlobal(loggerMock, validTreatment)).toEqual(validTreatment); + expect(sanitizeFallbacks(loggerMock, { ...fallbackMock, global: validTreatment })).toEqual({ ...fallbackMock, global: validTreatment }); expect(loggerMock.error).not.toHaveBeenCalled(); }); test('returns undefined and logs error if invalid', () => { - const result = FallbacksSanitizer.sanitizeGlobal(loggerMock, invalidTreatment); - expect(result).toBeUndefined(); + const result = sanitizeFallbacks(loggerMock, { ...fallbackMock, global: invalidTreatment }); + expect(result).toEqual(fallbackMock); expect(loggerMock.error).toHaveBeenCalledWith( expect.stringContaining('Fallback treatments - Discarded fallback') ); @@ -74,9 +79,9 @@ describe('FallbacksSanitizer', () => { bad_treatment: invalidTreatment, }; - const result = FallbacksSanitizer.sanitizeByFlag(loggerMock, input); + const result = sanitizeFallbacks(loggerMock, {...fallbackMock, byFlag: input}); - expect(result).toEqual({ valid_flag: validTreatment }); + expect(result).toEqual({ ...fallbackMock, byFlag: { valid_flag: validTreatment } }); expect(loggerMock.error).toHaveBeenCalledTimes(2); // invalid flag + bad_treatment }); @@ -85,20 +90,46 @@ describe('FallbacksSanitizer', () => { 'invalid flag': invalidTreatment, }; - const result = FallbacksSanitizer.sanitizeByFlag(loggerMock, input); - expect(result).toEqual({}); + const result = sanitizeFallbacks(loggerMock, {...fallbackMock, byFlag: input}); + expect(result).toEqual(fallbackMock); expect(loggerMock.error).toHaveBeenCalled(); }); test('returns same object if all valid', () => { const input = { - flag_one: validTreatment, - flag_two: { treatment: 'valid_2', config: null }, + ...fallbackMock, + byFlag:{ + flag_one: validTreatment, + flag_two: { treatment: 'valid_2', config: null }, + } }; - const result = FallbacksSanitizer.sanitizeByFlag(loggerMock, input); + const result = sanitizeFallbacks(loggerMock, input); expect(result).toEqual(input); expect(loggerMock.error).not.toHaveBeenCalled(); }); }); + + describe('sanitizeFallbacks', () => { + test('returns undefined and logs error if fallbacks is not an object', () => { // @ts-expect-error + const result = sanitizeFallbacks(loggerMock, 'invalid_fallbacks'); + expect(result).toBeUndefined(); + expect(loggerMock.error).toHaveBeenCalledWith( + 'Fallback treatments - Discarded configuration: it must be an object with optional `global` and `byFlag` properties' + ); + }); + + test('returns undefined and logs error if fallbacks is not an object', () => { // @ts-expect-error + const result = sanitizeFallbacks(loggerMock, true); + expect(result).toBeUndefined(); + expect(loggerMock.error).toHaveBeenCalledWith( + 'Fallback treatments - Discarded configuration: it must be an object with optional `global` and `byFlag` properties' + ); + }); + + test('sanitizes both global and byFlag fallbacks for empty object', () => { // @ts-expect-error + const result = sanitizeFallbacks(loggerMock, { global: {} }); + expect(result).toEqual({ global: undefined, byFlag: {} }); + }); + }); }); diff --git a/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts b/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts index dbeabe88..95ac25e1 100644 --- a/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts +++ b/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts @@ -1,4 +1,4 @@ -import { Treatment, TreatmentWithConfig } from '../../../../types/splitio'; +import { FallbackTreatmentConfiguration, Treatment, TreatmentWithConfig } from '../../../../types/splitio'; import { ILogger } from '../../../logger/types'; import { isObject, isString } from '../../../utils/lang'; @@ -7,59 +7,65 @@ enum FallbackDiscardReason { Treatment = 'Invalid treatment (max 100 chars and must match pattern)', } -export class FallbacksSanitizer { +const TREATMENT_PATTERN = /^[0-9]+[.a-zA-Z0-9_-]*$|^[a-zA-Z]+[a-zA-Z0-9_-]*$/; - 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(' '); - } +export function 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; +export function 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); + if (!isString(treatment) || treatment.length > 100) { + return false; } + return TREATMENT_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; +function sanitizeGlobal(logger: ILogger, treatment?: Treatment | TreatmentWithConfig): Treatment | TreatmentWithConfig | undefined { + if (treatment === undefined) return undefined; + if (!isValidTreatment(treatment)) { + logger.error(`Fallback treatments - Discarded fallback: ${FallbackDiscardReason.Treatment}`); + return undefined; } + return treatment; +} - static sanitizeByFlag( - logger: ILogger, - byFlagFallbacks: Record - ): Record { - const sanitizedByFlag: Record = {}; +function 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 (!isObject(byFlagFallbacks)) return sanitizedByFlag; - if (!this.isValidTreatment(t)) { - logger.error( - `Fallback treatments - Discarded treatment for flag '${flag}': ${FallbackDiscardReason.Treatment}` - ); - return; - } + Object.keys(byFlagFallbacks!).forEach((flag) => { + const t = byFlagFallbacks![flag]; - sanitizedByFlag[flag] = t; - }); + if (!isValidFlagName(flag)) { + logger.error(`Fallback treatments - Discarded flag '${flag}': ${FallbackDiscardReason.FlagName}`); + return; + } + + if (!isValidTreatment(t)) { + logger.error(`Fallback treatments - Discarded treatment for flag '${flag}': ${FallbackDiscardReason.Treatment}`); + return; + } + + sanitizedByFlag[flag] = t; + }); - return sanitizedByFlag; + return sanitizedByFlag; +} + +export function sanitizeFallbacks(logger: ILogger, fallbacks: FallbackTreatmentConfiguration): FallbackTreatmentConfiguration | undefined { + if (!isObject(fallbacks)) { + logger.error('Fallback treatments - Discarded configuration: it must be an object with optional `global` and `byFlag` properties'); + return; } + + return { + global: sanitizeGlobal(logger, fallbacks.global), + byFlag: sanitizeByFlag(logger, fallbacks.byFlag) + }; } diff --git a/src/evaluator/fallbackTreatmentsCalculator/index.ts b/src/evaluator/fallbackTreatmentsCalculator/index.ts index 7921f72c..e582aa97 100644 --- a/src/evaluator/fallbackTreatmentsCalculator/index.ts +++ b/src/evaluator/fallbackTreatmentsCalculator/index.ts @@ -1,8 +1,6 @@ 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 }; @@ -13,13 +11,8 @@ 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 - }; + constructor(fallbacks: FallbackTreatmentConfiguration = {}) { + this.fallbacks = fallbacks; } resolve(flagName: string, label: string): TreatmentWithConfig & { label: string } { diff --git a/src/sdkClient/__tests__/sdkClientMethod.spec.ts b/src/sdkClient/__tests__/sdkClientMethod.spec.ts index 31b62356..9120f20e 100644 --- a/src/sdkClient/__tests__/sdkClientMethod.spec.ts +++ b/src/sdkClient/__tests__/sdkClientMethod.spec.ts @@ -19,7 +19,7 @@ const paramMocks = [ telemetryTracker: telemetryTrackerFactory(), clients: {}, uniqueKeysTracker: { start: jest.fn(), stop: jest.fn() }, - fallbackTreatmentsCalculator: new FallbackTreatmentsCalculator(loggerMock, {}) + fallbackTreatmentsCalculator: new FallbackTreatmentsCalculator({}) }, // SyncManager (i.e., Sync SDK) and Signal listener { @@ -31,7 +31,7 @@ const paramMocks = [ telemetryTracker: telemetryTrackerFactory(), clients: {}, uniqueKeysTracker: { start: jest.fn(), stop: jest.fn() }, - fallbackTreatmentsCalculator: new FallbackTreatmentsCalculator(loggerMock, {}) + fallbackTreatmentsCalculator: new FallbackTreatmentsCalculator({}) } ]; diff --git a/src/sdkFactory/__tests__/index.spec.ts b/src/sdkFactory/__tests__/index.spec.ts index 2165bcd4..4b9f58ab 100644 --- a/src/sdkFactory/__tests__/index.spec.ts +++ b/src/sdkFactory/__tests__/index.spec.ts @@ -37,7 +37,7 @@ const paramsForAsyncSDK = { platform: { EventEmitter }, - fallbackTreatmentsCalculator: new FallbackTreatmentsCalculator(fullSettings.log) + fallbackTreatmentsCalculator: new FallbackTreatmentsCalculator() }; const SignalListenerInstanceMock = { start: jest.fn() }; diff --git a/src/sdkFactory/index.ts b/src/sdkFactory/index.ts index 5e38d5a7..02d3e0df 100644 --- a/src/sdkFactory/index.ts +++ b/src/sdkFactory/index.ts @@ -61,7 +61,7 @@ export function sdkFactory(params: ISdkFactoryParams): SplitIO.ISDK | SplitIO.IA } }); - const fallbackTreatmentsCalculator = new FallbackTreatmentsCalculator(settings.log, settings.fallbackTreatments); + const fallbackTreatmentsCalculator = new FallbackTreatmentsCalculator(settings.fallbackTreatments); if (initialRolloutPlan) { setRolloutPlan(log, initialRolloutPlan, storage as IStorageSync, key && getMatching(key)); diff --git a/src/utils/settingsValidation/index.ts b/src/utils/settingsValidation/index.ts index 2dc63018..8f070082 100644 --- a/src/utils/settingsValidation/index.ts +++ b/src/utils/settingsValidation/index.ts @@ -8,6 +8,7 @@ import { ISettings } from '../../types'; import { validateKey } from '../inputValidation/key'; import { ERROR_MIN_CONFIG_PARAM, LOG_PREFIX_CLIENT_INSTANTIATION } from '../../logger/constants'; import { validateRolloutPlan } from '../../storages/setRolloutPlan'; +import { sanitizeFallbacks } from '../../evaluator/fallbackTreatmentsCalculator/fallbackSanitizer'; // Exported for telemetry export const base = { @@ -207,5 +208,8 @@ export function settingsValidation(config: unknown, validationParams: ISettingsV // @ts-ignore, modify readonly prop withDefaults.userConsent = consent ? consent(withDefaults) : undefined; + // @ts-ignore, modify readonly prop + withDefaults.fallbackTreatments = withDefaults.fallbackTreatments ? sanitizeFallbacks(log, withDefaults.fallbackTreatments) : undefined; + return withDefaults; } diff --git a/types/splitio.d.ts b/types/splitio.d.ts index a2903c27..bd7da67c 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -96,6 +96,12 @@ interface ISharedSettings { * Set together with `debug` option to `true` or a log level string to enable logging. */ logger?: SplitIO.Logger; + /** + * Fallback treatments to be used when the SDK is not ready or the flag is not found. + * + * @defaultValue `undefined` + */ + fallbackTreatments?: SplitIO.FallbackTreatmentConfiguration; } /** * Common settings properties for SDKs with synchronous API (standalone and localhost modes). @@ -713,7 +719,7 @@ declare namespace SplitIO { isReadyFromCache: boolean; /** - * `isTimedout` indicates if the client has triggered an `SDK_READY_TIMED_OUT` event and is not ready to evaluate. + * `isTimedout` indicates if the client has triggered an `SDK_READY_TIMED_OUT` event and is not ready. * In other words, `isTimedout` is equivalent to `hasTimedout && !isReady`. */ isTimedout: boolean; @@ -1303,7 +1309,13 @@ declare namespace SplitIO { * Fallback treatments to be used when the SDK is not ready or the flag is not found. */ type FallbackTreatmentConfiguration = { + /** + * Fallback treatment for all flags. + */ global?: Treatment | TreatmentWithConfig, + /** + * Fallback treatments for specific flags. It takes precedence over the global fallback treatment. + */ byFlag?: { [featureFlagName: string]: Treatment | TreatmentWithConfig }