From 2836af347e2d21ca98ca3b86860f37855f86ef71 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Tue, 15 Apr 2025 13:49:49 -0300 Subject: [PATCH 01/35] Add checkIfServerSide utility function and update client and server-side detection --- src/storages/inMemory/TelemetryCacheInMemory.ts | 3 ++- src/storages/pluggable/index.ts | 3 ++- src/sync/polling/fetchers/splitChangesFetcher.ts | 3 ++- src/sync/streaming/SSEClient/index.ts | 3 ++- src/sync/streaming/pushManager.ts | 4 ++-- src/sync/submitters/telemetrySubmitter.ts | 7 ++++--- src/utils/key/index.ts | 5 +++++ 7 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/storages/inMemory/TelemetryCacheInMemory.ts b/src/storages/inMemory/TelemetryCacheInMemory.ts index 7e7e3f98..b6247f18 100644 --- a/src/storages/inMemory/TelemetryCacheInMemory.ts +++ b/src/storages/inMemory/TelemetryCacheInMemory.ts @@ -1,5 +1,6 @@ import { ImpressionDataType, EventDataType, LastSync, HttpErrors, HttpLatencies, StreamingEvent, Method, OperationType, MethodExceptions, MethodLatencies, TelemetryUsageStatsPayload, UpdatesFromSSEEnum, UpdatesFromSSE } from '../../sync/submitters/types'; import { DEDUPED, DROPPED, LOCALHOST_MODE, QUEUED } from '../../utils/constants'; +import { checkIfServerSide } from '../../utils/key'; import { findLatencyIndex } from '../findLatencyIndex'; import { ISegmentsCacheSync, ISplitsCacheSync, IStorageFactoryParams, ITelemetryCacheSync } from '../types'; @@ -20,7 +21,7 @@ const ACCEPTANCE_RANGE = 0.001; * All factory instances track telemetry on server-side, and 0.1% on client-side. */ export function shouldRecordTelemetry({ settings }: IStorageFactoryParams) { - return settings.mode !== LOCALHOST_MODE && (settings.core.key === undefined || Math.random() <= ACCEPTANCE_RANGE); + return settings.mode !== LOCALHOST_MODE && (checkIfServerSide(settings) || Math.random() <= ACCEPTANCE_RANGE); } export class TelemetryCacheInMemory implements ITelemetryCacheSync { diff --git a/src/storages/pluggable/index.ts b/src/storages/pluggable/index.ts index a5dba66e..7f020fa6 100644 --- a/src/storages/pluggable/index.ts +++ b/src/storages/pluggable/index.ts @@ -21,6 +21,7 @@ import { UniqueKeysCacheInMemoryCS } from '../inMemory/UniqueKeysCacheInMemoryCS import { metadataBuilder } from '../utils'; import { LOG_PREFIX } from '../pluggable/constants'; import { RBSegmentsCachePluggable } from './RBSegmentsCachePluggable'; +import { checkIfServerSide } from '../../utils/key'; const NO_VALID_WRAPPER = 'Expecting pluggable storage `wrapper` in options, but no valid wrapper instance was provided.'; const NO_VALID_WRAPPER_INTERFACE = 'The provided wrapper instance doesn’t follow the expected interface. Check our docs.'; @@ -83,7 +84,7 @@ export function PluggableStorage(options: PluggableStorageOptions): IStorageAsyn new ImpressionCountsCachePluggable(log, keys.buildImpressionsCountKey(), wrapper); const uniqueKeysCache = isPartialConsumer ? - settings.core.key === undefined ? new UniqueKeysCacheInMemory() : new UniqueKeysCacheInMemoryCS() : + checkIfServerSide(settings) ? new UniqueKeysCacheInMemory() : new UniqueKeysCacheInMemoryCS() : new UniqueKeysCachePluggable(log, keys.buildUniqueKeysKey(), wrapper); // Connects to wrapper and emits SDK_READY event on main client diff --git a/src/sync/polling/fetchers/splitChangesFetcher.ts b/src/sync/polling/fetchers/splitChangesFetcher.ts index 58f87e9a..b109eaec 100644 --- a/src/sync/polling/fetchers/splitChangesFetcher.ts +++ b/src/sync/polling/fetchers/splitChangesFetcher.ts @@ -6,6 +6,7 @@ import { FLAG_SPEC_VERSION } from '../../../utils/constants'; import { base } from '../../../utils/settingsValidation'; import { ISplitChangesFetcher } from './types'; import { LOG_PREFIX_SYNC_SPLITS } from '../../../logger/constants'; +import { checkIfServerSide } from '../../../utils/key'; const PROXY_CHECK_INTERVAL_MILLIS_CS = 60 * 60 * 1000; // 1 hour in Client Side const PROXY_CHECK_INTERVAL_MILLIS_SS = 24 * PROXY_CHECK_INTERVAL_MILLIS_CS; // 24 hours in Server Side @@ -22,7 +23,7 @@ function sdkEndpointOverriden(settings: ISettings) { export function splitChangesFetcherFactory(fetchSplitChanges: IFetchSplitChanges, settings: ISettings, storage: Pick): ISplitChangesFetcher { const log = settings.log; - const PROXY_CHECK_INTERVAL_MILLIS = settings.core.key !== undefined ? PROXY_CHECK_INTERVAL_MILLIS_CS : PROXY_CHECK_INTERVAL_MILLIS_SS; + const PROXY_CHECK_INTERVAL_MILLIS = checkIfServerSide(settings) ? PROXY_CHECK_INTERVAL_MILLIS_SS : PROXY_CHECK_INTERVAL_MILLIS_CS; let lastProxyCheckTimestamp: number | undefined; return function splitChangesFetcher( diff --git a/src/sync/streaming/SSEClient/index.ts b/src/sync/streaming/SSEClient/index.ts index c19c2817..bf6a2ee3 100644 --- a/src/sync/streaming/SSEClient/index.ts +++ b/src/sync/streaming/SSEClient/index.ts @@ -2,6 +2,7 @@ import { IPlatform } from '../../../sdkFactory/types'; import { decorateHeaders } from '../../../services/decorateHeaders'; import { IEventSourceConstructor } from '../../../services/types'; import { ISettings } from '../../../types'; +import { checkIfServerSide } from '../../../utils/key'; import { isString } from '../../../utils/lang'; import { objectAssign } from '../../../utils/lang/objectAssign'; import { IAuthTokenPushEnabled } from '../AuthClient/types'; @@ -73,7 +74,7 @@ export class SSEClient implements ISSEClient { return encodeURIComponent(params + channel); }).join(','); const url = `${this.settings.urls.streaming}/sse?channels=${channelsQueryParam}&accessToken=${authToken.token}&v=${ABLY_API_VERSION}&heartbeats=true`; // same results using `&heartbeats=false` - const isServerSide = !this.settings.core.key; + const isServerSide = checkIfServerSide(this.settings); this.connection = new this.eventSource!( // For client-side SDKs, metadata is passed as query param to avoid CORS issues and because native EventSource implementations in browsers do not support headers diff --git a/src/sync/streaming/pushManager.ts b/src/sync/streaming/pushManager.ts index 9122c176..f0a5ac4e 100644 --- a/src/sync/streaming/pushManager.ts +++ b/src/sync/streaming/pushManager.ts @@ -10,7 +10,7 @@ import { SplitsUpdateWorker } from './UpdateWorkers/SplitsUpdateWorker'; import { authenticateFactory, hashUserKey } from './AuthClient'; import { forOwn } from '../../utils/lang'; import { SSEClient } from './SSEClient'; -import { getMatching } from '../../utils/key'; +import { checkIfServerSide, getMatching } from '../../utils/key'; import { MEMBERSHIPS_MS_UPDATE, MEMBERSHIPS_LS_UPDATE, PUSH_NONRETRYABLE_ERROR, PUSH_SUBSYSTEM_DOWN, SECONDS_BEFORE_EXPIRATION, SEGMENT_UPDATE, SPLIT_KILL, SPLIT_UPDATE, RB_SEGMENT_UPDATE, PUSH_RETRYABLE_ERROR, PUSH_SUBSYSTEM_UP, ControlType } from './constants'; import { STREAMING_FALLBACK, STREAMING_REFRESH_TOKEN, STREAMING_CONNECTING, STREAMING_DISABLED, ERROR_STREAMING_AUTH, STREAMING_DISCONNECTING, STREAMING_RECONNECT, STREAMING_PARSING_MEMBERSHIPS_UPDATE } from '../../logger/constants'; import { IMembershipMSUpdateData, IMembershipLSUpdateData, KeyList, UpdateStrategy } from './SSEHandler/types'; @@ -34,7 +34,7 @@ export function pushManagerFactory( // `userKey` is the matching key of main client in client-side SDK. // It can be used to check if running on client-side or server-side SDK. - const userKey = settings.core.key ? getMatching(settings.core.key) : undefined; + const userKey = checkIfServerSide(settings) ? undefined : getMatching(settings.core.key); const log = settings.log; let sseClient: ISSEClient; diff --git a/src/sync/submitters/telemetrySubmitter.ts b/src/sync/submitters/telemetrySubmitter.ts index 7a2e2ee7..52bde5b1 100644 --- a/src/sync/submitters/telemetrySubmitter.ts +++ b/src/sync/submitters/telemetrySubmitter.ts @@ -11,6 +11,7 @@ import { timer } from '../../utils/timeTracker/timer'; import { ISdkFactoryContextSync } from '../../sdkFactory/types'; import { objectAssign } from '../../utils/lang/objectAssign'; import { ISplitFiltersValidation } from '../../dtos/types'; +import { checkIfServerSide } from '../../utils/key'; const OPERATION_MODE_MAP = { [STANDALONE_MODE]: STANDALONE_ENUM, @@ -71,7 +72,7 @@ export function telemetryCacheConfigAdapter(telemetry: ITelemetryCacheSync, sett pop(): TelemetryConfigStatsPayload { const { urls, scheduler } = settings; - const isClientSide = settings.core.key !== undefined; + const isServerSide = checkIfServerSide(settings); const { flagSetsTotal, flagSetsIgnored } = getTelemetryFlagSetsStats(settings.sync.__splitFiltersValidation); @@ -79,8 +80,8 @@ export function telemetryCacheConfigAdapter(telemetry: ITelemetryCacheSync, sett sE: settings.streamingEnabled, rR: { sp: scheduler.featuresRefreshRate / 1000, - se: isClientSide ? undefined : scheduler.segmentsRefreshRate / 1000, - ms: isClientSide ? scheduler.segmentsRefreshRate / 1000 : undefined, + se: isServerSide ? scheduler.segmentsRefreshRate / 1000 : undefined, + ms: isServerSide ? undefined : scheduler.segmentsRefreshRate / 1000, im: scheduler.impressionsRefreshRate / 1000, ev: scheduler.eventsPushRate / 1000, te: scheduler.telemetryRefreshRate / 1000, diff --git a/src/utils/key/index.ts b/src/utils/key/index.ts index fc763b6e..2fb1e0f1 100644 --- a/src/utils/key/index.ts +++ b/src/utils/key/index.ts @@ -1,4 +1,5 @@ import SplitIO from '../../../types/splitio'; +import { ISettings } from '../../types'; import { isObject } from '../lang'; // function isSplitKeyObject(key: any): key is SplitIO.SplitKeyObject { @@ -32,3 +33,7 @@ export function keyParser(key: SplitIO.SplitKey): SplitIO.SplitKeyObject { }; } } + +export function checkIfServerSide(settings: ISettings) { + return !settings.core.key; +} From 1eeff81e5153f976290980c5e7bbcc7fe6146298 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Wed, 1 Oct 2025 11:39:56 -0300 Subject: [PATCH 02/35] Add method to retrieve client readiness status synchronously --- .../__tests__/sdkReadinessManager.spec.ts | 4 +- src/readiness/sdkReadinessManager.ts | 2 +- src/readiness/types.ts | 3 +- src/types.ts | 15 ------ types/splitio.d.ts | 52 +++++++++++++++++++ 5 files changed, 56 insertions(+), 20 deletions(-) diff --git a/src/readiness/__tests__/sdkReadinessManager.spec.ts b/src/readiness/__tests__/sdkReadinessManager.spec.ts index 9044fc72..35ee9d7a 100644 --- a/src/readiness/__tests__/sdkReadinessManager.spec.ts +++ b/src/readiness/__tests__/sdkReadinessManager.spec.ts @@ -51,8 +51,8 @@ describe('SDK Readiness Manager - Event emitter', () => { }); expect(typeof sdkStatus.ready).toBe('function'); // The sdkStatus exposes a .ready() function. - expect(typeof sdkStatus.__getStatus).toBe('function'); // The sdkStatus exposes a .__getStatus() function. - expect(sdkStatus.__getStatus()).toEqual({ + expect(typeof sdkStatus.getStatus).toBe('function'); // The sdkStatus exposes a .getStatus() function. + expect(sdkStatus.getStatus()).toEqual({ isReady: false, isReadyFromCache: false, isTimedout: false, hasTimedout: false, isDestroyed: false, isOperational: false, lastUpdate: 0 }); diff --git a/src/readiness/sdkReadinessManager.ts b/src/readiness/sdkReadinessManager.ts index ee558d47..3f3de706 100644 --- a/src/readiness/sdkReadinessManager.ts +++ b/src/readiness/sdkReadinessManager.ts @@ -104,7 +104,7 @@ export function sdkReadinessManagerFactory( return readyPromise; }, - __getStatus() { + getStatus() { return { isReady: readinessManager.isReady(), isReadyFromCache: readinessManager.isReadyFromCache(), diff --git a/src/readiness/types.ts b/src/readiness/types.ts index df3c2603..2de99b43 100644 --- a/src/readiness/types.ts +++ b/src/readiness/types.ts @@ -1,4 +1,3 @@ -import { IStatusInterface } from '../types'; import SplitIO from '../../types/splitio'; /** Splits data emitter */ @@ -72,7 +71,7 @@ export interface IReadinessManager { export interface ISdkReadinessManager { readinessManager: IReadinessManager - sdkStatus: IStatusInterface + sdkStatus: SplitIO.IStatusInterface /** * Increment internalReadyCbCount, an offset value of SDK_READY listeners that are added/removed internally diff --git a/src/types.ts b/src/types.ts index ad3fa04c..5f6c7e39 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,21 +14,6 @@ export interface ISettings extends SplitIO.ISettings { readonly initialRolloutPlan?: RolloutPlan; } -/** - * SplitIO.IStatusInterface interface extended with private properties for internal use - */ -export interface IStatusInterface extends SplitIO.IStatusInterface { - // Expose status for internal purposes only. Not considered part of the public API, and might be updated eventually. - __getStatus(): { - isReady: boolean; - isReadyFromCache: boolean; - isTimedout: boolean; - hasTimedout: boolean; - isDestroyed: boolean; - isOperational: boolean; - lastUpdate: number; - }; -} /** * SplitIO.IBasicClient interface extended with private properties for internal use */ diff --git a/types/splitio.d.ts b/types/splitio.d.ts index eaa490f3..364c5208 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -668,6 +668,52 @@ declare namespace SplitIO { [status in ConsentStatus]: ConsentStatus; }; } + /** + * Readiness Status interface. It represents the readiness state of an SDK client. + */ + interface ReadinessStatus { + + /** + * `isReady` indicates if the client has triggered an `SDK_READY` event and + * thus is ready to evaluate with cached data synchronized with the backend. + */ + isReady: boolean; + + /** + * `isReadyFromCache` indicates if the client has triggered an `SDK_READY_FROM_CACHE` event and + * thus is ready to evaluate with cached data, although the data in cache might be stale. + */ + isReadyFromCache: boolean; + + /** + * `isTimedout` indicates if the client has triggered an `SDK_READY_TIMED_OUT` event and is not ready to evaluate. + * In other words, `isTimedout` is equivalent to `hasTimedout && !isReady`. + */ + isTimedout: boolean; + + /** + * `hasTimedout` indicates if the client has ever triggered an `SDK_READY_TIMED_OUT` event. + * It's meant to keep a reference that the SDK emitted a timeout at some point, not the current state. + */ + hasTimedout: boolean; + + /** + * `isDestroyed` indicates if the client has been destroyed, i.e., `destroy` method has been called. + */ + isDestroyed: boolean; + + /** + * `isOperational` indicates if the client can evaluate feature flags. + * In this state, `getTreatment` calls will not return `CONTROL` due to the SDK being unready or destroyed. + * It's equivalent to `(isReady || isReadyFromCache) && !isDestroyed`. + */ + isOperational: boolean; + + /** + * `lastUpdate` indicates the timestamp of the most recent status event. + */ + lastUpdate: number; + } /** * Common API for entities that expose status handlers. */ @@ -676,6 +722,12 @@ declare namespace SplitIO { * Constant object containing the SDK events for you to use. */ Event: EventConsts; + /** + * Gets the readiness status. + * + * @returns The current readiness status. + */ + getStatus(): ReadinessStatus; /** * Returns a promise that resolves once the SDK has finished loading (`SDK_READY` event emitted) or rejected if the SDK has timedout (`SDK_READY_TIMED_OUT` event emitted). * As it's meant to provide similar flexibility to the event approach, given that the SDK might be eventually ready after a timeout event, the `ready` method will return a resolved promise once the SDK is ready. From 850b0748956e4765c67d9a451056b4ea3605652d Mon Sep 17 00:00:00 2001 From: Emmanuel Zamora Date: Thu, 9 Oct 2025 16:04:14 -0300 Subject: [PATCH 03/35] Implement FallbackSanitizer and add fallback to config --- .../__tests__/fallback-sanitizer.spec.ts | 103 ++++++++++++++++++ .../fallbackTreatmentsCalculator/constants.ts | 4 + .../fallbackSanitizer/index.ts | 63 +++++++++++ types/splitio.d.ts | 13 +++ 4 files changed, 183 insertions(+) create mode 100644 src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts create mode 100644 src/evaluator/fallbackTreatmentsCalculator/constants.ts create mode 100644 src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts 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..e063a570 --- /dev/null +++ b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts @@ -0,0 +1,103 @@ +import { FallbacksSanitizer } from '../fallbackSanitizer'; +import { TreatmentWithConfig } from '../../../../types/splitio'; + +describe('FallbacksSanitizer', () => { + const validTreatment: TreatmentWithConfig = { treatment: 'on', config: '{"color":"blue"}' }; + const invalidTreatment: TreatmentWithConfig = { treatment: ' ', config: null }; + + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + (console.error as jest.Mock).mockRestore(); + }); + + describe('isValidFlagName', () => { + it('returns true for a valid flag name', () => { + // @ts-expect-private-access + expect((FallbacksSanitizer as any).isValidFlagName('my_flag')).toBe(true); + }); + + it('returns false for a name longer than 100 chars', () => { + const longName = 'a'.repeat(101); + expect((FallbacksSanitizer as any).isValidFlagName(longName)).toBe(false); + }); + + it('returns false if the name contains spaces', () => { + expect((FallbacksSanitizer as any).isValidFlagName('invalid flag')).toBe(false); + }); + }); + + describe('isValidTreatment', () => { + it('returns true for a valid treatment string', () => { + expect((FallbacksSanitizer as any).isValidTreatment(validTreatment)).toBe(true); + }); + + it('returns false for null or undefined', () => { + expect((FallbacksSanitizer as any).isValidTreatment(null)).toBe(false); + expect((FallbacksSanitizer as any).isValidTreatment(undefined)).toBe(false); + }); + + it('returns false for a treatment longer than 100 chars', () => { + const long = { treatment: 'a'.repeat(101) }; + expect((FallbacksSanitizer as any).isValidTreatment(long)).toBe(false); + }); + + it('returns false if treatment does not match regex pattern', () => { + const invalid = { treatment: 'invalid treatment!' }; + expect((FallbacksSanitizer as any).isValidTreatment(invalid)).toBe(false); + }); + }); + + describe('sanitizeGlobal', () => { + it('returns the treatment if valid', () => { + expect(FallbacksSanitizer.sanitizeGlobal(validTreatment)).toEqual(validTreatment); + expect(console.error).not.toHaveBeenCalled(); + }); + + it('returns undefined and logs error if invalid', () => { + const result = FallbacksSanitizer.sanitizeGlobal(invalidTreatment); + expect(result).toBeUndefined(); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('Fallback treatments - Discarded fallback') + ); + }); + }); + + describe('sanitizeByFlag', () => { + it('returns a sanitized map with valid entries only', () => { + const input = { + valid_flag: validTreatment, + 'invalid flag': validTreatment, + bad_treatment: invalidTreatment, + }; + + const result = FallbacksSanitizer.sanitizeByFlag(input); + + expect(result).toEqual({ valid_flag: validTreatment }); + expect(console.error).toHaveBeenCalledTimes(2); // invalid flag + bad_treatment + }); + + it('returns empty object if all invalid', () => { + const input = { + 'invalid flag': invalidTreatment, + }; + + const result = FallbacksSanitizer.sanitizeByFlag(input); + expect(result).toEqual({}); + expect(console.error).toHaveBeenCalled(); + }); + + it('returns same object if all valid', () => { + const input = { + flag_one: validTreatment, + flag_two: { treatment: 'valid_2', config: null }, + }; + + const result = FallbacksSanitizer.sanitizeByFlag(input); + expect(result).toEqual(input); + expect(console.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..a8e07e34 --- /dev/null +++ b/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts @@ -0,0 +1,63 @@ +import { TreatmentWithConfig } from '../../../../types/splitio'; +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?: TreatmentWithConfig): boolean { + if (!t || !t.treatment) { + return false; + } + + const { treatment } = t; + + if (treatment.length > 100) { + return false; + } + + return FallbacksSanitizer.pattern.test(treatment); + } + + static sanitizeGlobal(treatment?: TreatmentWithConfig): TreatmentWithConfig | undefined { + if (!this.isValidTreatment(treatment)) { + console.error( + `Fallback treatments - Discarded fallback: ${FallbackDiscardReason.Treatment}` + ); + return undefined; + } + return treatment!; + } + + static sanitizeByFlag( + byFlagFallbacks: Record + ): Record { + const sanitizedByFlag: Record = {}; + + const entries = Object.entries(byFlagFallbacks); + entries.forEach(([flag, t]) => { + if (!this.isValidFlagName(flag)) { + console.error( + `Fallback treatments - Discarded flag '${flag}': ${FallbackDiscardReason.FlagName}` + ); + return; + } + + if (!this.isValidTreatment(t)) { + console.error( + `Fallback treatments - Discarded treatment for flag '${flag}': ${FallbackDiscardReason.Treatment}` + ); + return; + } + + sanitizedByFlag[flag] = t; + }); + + return sanitizedByFlag; + } +} diff --git a/types/splitio.d.ts b/types/splitio.d.ts index ad8644b2..510adfbf 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -579,6 +579,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?: FallbackTreatmentOptions; } /** * Log levels. @@ -1142,6 +1146,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 FallbackTreatmentOptions = { + global?: TreatmentWithConfig | Treatment, + byFlag: { + [key: string]: TreatmentWithConfig | Treatment + } + } /** * Logger. Its interface details are not part of the public API. It shouldn't be used directly. */ From 9163c486e0356a1b76a0efdd0dcf1d4702dfd292 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Tue, 14 Oct 2025 15:42:58 -0300 Subject: [PATCH 04/35] Fix typos --- src/sync/streaming/SSEHandler/index.ts | 2 +- src/sync/submitters/telemetrySubmitter.ts | 6 +++--- src/trackers/telemetryTracker.ts | 12 ++++++------ 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/sync/streaming/SSEHandler/index.ts b/src/sync/streaming/SSEHandler/index.ts index f7a39c8b..4de5ed9d 100644 --- a/src/sync/streaming/SSEHandler/index.ts +++ b/src/sync/streaming/SSEHandler/index.ts @@ -25,7 +25,7 @@ export function SSEHandlerFactory(log: ILogger, pushEmitter: IPushEventEmitter, const code = error.parsedData.code; telemetryTracker.streamingEvent(ABLY_ERROR, code); - // 401 errors due to invalid or expired token (e.g., if refresh token coudn't be executed) + // 401 errors due to invalid or expired token (e.g., if refresh token couldn't be executed) if (40140 <= code && code <= 40149) return true; // Others 4XX errors (e.g., bad request from the SDK) if (40000 <= code && code <= 49999) return false; diff --git a/src/sync/submitters/telemetrySubmitter.ts b/src/sync/submitters/telemetrySubmitter.ts index 82fba1c5..74a6c82e 100644 --- a/src/sync/submitters/telemetrySubmitter.ts +++ b/src/sync/submitters/telemetrySubmitter.ts @@ -119,7 +119,7 @@ export function telemetrySubmitterFactory(params: ISdkFactoryContextSync) { if (!telemetry || !now) return; // No submitter created if telemetry cache is not defined const { settings, settings: { log, scheduler: { telemetryRefreshRate } }, splitApi, readiness, sdkReadinessManager } = params; - const startTime = timer(now); + const stopTimer = timer(now); const submitter = firstPushWindowDecorator( submitterFactory( @@ -131,12 +131,12 @@ export function telemetrySubmitterFactory(params: ISdkFactoryContextSync) { ); readiness.gate.once(SDK_READY_FROM_CACHE, () => { - telemetry.recordTimeUntilReadyFromCache(startTime()); + telemetry.recordTimeUntilReadyFromCache(stopTimer()); }); sdkReadinessManager.incInternalReadyCbCount(); readiness.gate.once(SDK_READY, () => { - telemetry.recordTimeUntilReady(startTime()); + telemetry.recordTimeUntilReady(stopTimer()); // Post config data when the SDK is ready and if the telemetry submitter was started if (submitter.isRunning()) { diff --git a/src/trackers/telemetryTracker.ts b/src/trackers/telemetryTracker.ts index 0312cc94..1a0ebc6e 100644 --- a/src/trackers/telemetryTracker.ts +++ b/src/trackers/telemetryTracker.ts @@ -11,11 +11,11 @@ export function telemetryTrackerFactory( ): ITelemetryTracker { if (telemetryCache && now) { - const startTime = timer(now); + const sessionTimer = timer(now); return { trackEval(method) { - const evalTime = timer(now); + const evalTimer = timer(now); return (label) => { switch (label) { @@ -25,20 +25,20 @@ export function telemetryTrackerFactory( case SDK_NOT_READY: // @ts-ignore ITelemetryCacheAsync doesn't implement the method if (telemetryCache.recordNonReadyUsage) telemetryCache.recordNonReadyUsage(); } - telemetryCache.recordLatency(method, evalTime()); + telemetryCache.recordLatency(method, evalTimer()); }; }, trackHttp(operation) { - const httpTime = timer(now); + const httpTimer = timer(now); return (error) => { - (telemetryCache as ITelemetryCacheSync).recordHttpLatency(operation, httpTime()); + (telemetryCache as ITelemetryCacheSync).recordHttpLatency(operation, httpTimer()); if (error && error.statusCode) (telemetryCache as ITelemetryCacheSync).recordHttpError(operation, error.statusCode); else (telemetryCache as ITelemetryCacheSync).recordSuccessfulSync(operation, Date.now()); }; }, sessionLength() { // @ts-ignore ITelemetryCacheAsync doesn't implement the method - if (telemetryCache.recordSessionLength) telemetryCache.recordSessionLength(startTime()); + if (telemetryCache.recordSessionLength) telemetryCache.recordSessionLength(sessionTimer()); }, streamingEvent(e, d) { if (e === AUTH_REJECTION) { From 1724ce89e5282c2d05fa89043ac7d83a6e8a5af2 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Tue, 14 Oct 2025 15:47:48 -0300 Subject: [PATCH 05/35] Updated SDK_READY_FROM_CACHE event to be emitted alongside the SDK_READY event --- CHANGES.txt | 3 +++ src/readiness/readinessManager.ts | 3 +-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index 2b93b7fc..fdab3d6d 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,6 @@ +2.8.0 (October XX, 2025) + - Updated the SDK_READY_FROM_CACHE event to be emitted alongside the SDK_READY event if it hasn’t already been emitted. + 2.7.1 (October 8, 2025) - Bugfix - Update `debug` option to support log levels when `logger` option is used. diff --git a/src/readiness/readinessManager.ts b/src/readiness/readinessManager.ts index c69eedce..8a93d03c 100644 --- a/src/readiness/readinessManager.ts +++ b/src/readiness/readinessManager.ts @@ -3,7 +3,6 @@ import { ISettings } from '../types'; import SplitIO from '../../types/splitio'; import { SDK_SPLITS_ARRIVED, SDK_SPLITS_CACHE_LOADED, SDK_SEGMENTS_ARRIVED, SDK_READY_TIMED_OUT, SDK_READY_FROM_CACHE, SDK_UPDATE, SDK_READY } from './constants'; import { IReadinessEventEmitter, IReadinessManager, ISegmentsEventEmitter, ISplitsEventEmitter } from './types'; -import { STORAGE_LOCALSTORAGE } from '../utils/constants'; function splitsEventEmitterFactory(EventEmitter: new () => SplitIO.IEventEmitter): ISplitsEventEmitter { const splitsEventEmitter = objectAssign(new EventEmitter(), { @@ -115,7 +114,7 @@ export function readinessManagerFactory( isReady = true; try { syncLastUpdate(); - if (!isReadyFromCache && settings.storage?.type === STORAGE_LOCALSTORAGE) { + if (!isReadyFromCache) { isReadyFromCache = true; gate.emit(SDK_READY_FROM_CACHE); } From 1a05cee975be123af2dd1a2093bd303e978b20b2 Mon Sep 17 00:00:00 2001 From: Emmanuel Zamora Date: Thu, 16 Oct 2025 14:52:25 -0300 Subject: [PATCH 06/35] [FME-10566] Create FallbackTreatmentsCalculator --- .../__tests__/fallback-calculator.spec.ts | 66 +++++++++++++++++++ .../__tests__/fallback-sanitizer.spec.ts | 24 +++---- .../fallbackSanitizer/index.ts | 25 ++++--- .../fallbackTreatmentsCalculator/index.ts | 49 ++++++++++++++ types/splitio.d.ts | 19 ++++-- 5 files changed, 157 insertions(+), 26 deletions(-) create mode 100644 src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts create mode 100644 src/evaluator/fallbackTreatmentsCalculator/index.ts 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..786ecbe8 --- /dev/null +++ b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts @@ -0,0 +1,66 @@ +import { FallbackTreatmentsCalculator } from '../'; +import type { FallbackTreatmentConfiguration } from '../../../../types/splitio'; // adjust path if needed + +describe('FallbackTreatmentsCalculator', () => { + + test('returns specific fallback if flag exists', () => { + const config: FallbackTreatmentConfiguration = { + byFlag: { + 'featureA': { treatment: 'TREATMENT_A', config: '{ value: 1 }' }, + }, + }; + const calculator = new FallbackTreatmentsCalculator(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(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(config); + const result = calculator.resolve('missingFlag', 'label by noFallback'); + + expect(result).toEqual({ + treatment: 'CONTROL', + config: null, + label: 'fallback - label by noFallback', + }); + }); + + test('returns undefined label if no label provided', () => { + const config: FallbackTreatmentConfiguration = { + byFlag: { + 'featureB': { treatment: 'TREATMENT_B' }, + }, + }; + const calculator = new FallbackTreatmentsCalculator(config); + const result = calculator.resolve('featureB'); + + expect(result).toEqual({ + treatment: 'TREATMENT_B', + config: undefined, + label: undefined, + }); + }); +}); diff --git a/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts index e063a570..0f314e2a 100644 --- a/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts +++ b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts @@ -14,49 +14,49 @@ describe('FallbacksSanitizer', () => { }); describe('isValidFlagName', () => { - it('returns true for a valid flag name', () => { + test('returns true for a valid flag name', () => { // @ts-expect-private-access expect((FallbacksSanitizer as any).isValidFlagName('my_flag')).toBe(true); }); - it('returns false for a name longer than 100 chars', () => { + test('returns false for a name longer than 100 chars', () => { const longName = 'a'.repeat(101); expect((FallbacksSanitizer as any).isValidFlagName(longName)).toBe(false); }); - it('returns false if the name contains spaces', () => { + test('returns false if the name contains spaces', () => { expect((FallbacksSanitizer as any).isValidFlagName('invalid flag')).toBe(false); }); }); describe('isValidTreatment', () => { - it('returns true for a valid treatment string', () => { + test('returns true for a valid treatment string', () => { expect((FallbacksSanitizer as any).isValidTreatment(validTreatment)).toBe(true); }); - it('returns false for null or undefined', () => { + test('returns false for null or undefined', () => { expect((FallbacksSanitizer as any).isValidTreatment(null)).toBe(false); expect((FallbacksSanitizer as any).isValidTreatment(undefined)).toBe(false); }); - it('returns false for a treatment longer than 100 chars', () => { + test('returns false for a treatment longer than 100 chars', () => { const long = { treatment: 'a'.repeat(101) }; expect((FallbacksSanitizer as any).isValidTreatment(long)).toBe(false); }); - it('returns false if treatment does not match regex pattern', () => { + 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', () => { - it('returns the treatment if valid', () => { + test('returns the treatment if valid', () => { expect(FallbacksSanitizer.sanitizeGlobal(validTreatment)).toEqual(validTreatment); expect(console.error).not.toHaveBeenCalled(); }); - it('returns undefined and logs error if invalid', () => { + test('returns undefined and logs error if invalid', () => { const result = FallbacksSanitizer.sanitizeGlobal(invalidTreatment); expect(result).toBeUndefined(); expect(console.error).toHaveBeenCalledWith( @@ -66,7 +66,7 @@ describe('FallbacksSanitizer', () => { }); describe('sanitizeByFlag', () => { - it('returns a sanitized map with valid entries only', () => { + test('returns a sanitized map with valid entries only', () => { const input = { valid_flag: validTreatment, 'invalid flag': validTreatment, @@ -79,7 +79,7 @@ describe('FallbacksSanitizer', () => { expect(console.error).toHaveBeenCalledTimes(2); // invalid flag + bad_treatment }); - it('returns empty object if all invalid', () => { + test('returns empty object if all invalid', () => { const input = { 'invalid flag': invalidTreatment, }; @@ -89,7 +89,7 @@ describe('FallbacksSanitizer', () => { expect(console.error).toHaveBeenCalled(); }); - it('returns same object if all valid', () => { + test('returns same object if all valid', () => { const input = { flag_one: validTreatment, flag_two: { treatment: 'valid_2', config: null }, diff --git a/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts b/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts index a8e07e34..06c7fe2e 100644 --- a/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts +++ b/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts @@ -1,4 +1,4 @@ -import { TreatmentWithConfig } from '../../../../types/splitio'; +import { FallbackTreatment } from '../../../../types/splitio'; import { FallbackDiscardReason } from '../constants'; @@ -10,21 +10,26 @@ export class FallbacksSanitizer { return name.length <= 100 && !name.includes(' '); } - private static isValidTreatment(t?: TreatmentWithConfig): boolean { - if (!t || !t.treatment) { + private static isValidTreatment(t?: FallbackTreatment): boolean { + if (!t) { return false; } - const { treatment } = t; + if (typeof t === 'string') { + if (t.length > 100) { + return false; + } + return FallbacksSanitizer.pattern.test(t); + } - if (treatment.length > 100) { + const { treatment } = t; + if (!treatment || treatment.length > 100) { return false; } - return FallbacksSanitizer.pattern.test(treatment); } - static sanitizeGlobal(treatment?: TreatmentWithConfig): TreatmentWithConfig | undefined { + static sanitizeGlobal(treatment?: FallbackTreatment): FallbackTreatment | undefined { if (!this.isValidTreatment(treatment)) { console.error( `Fallback treatments - Discarded fallback: ${FallbackDiscardReason.Treatment}` @@ -35,9 +40,9 @@ export class FallbacksSanitizer { } static sanitizeByFlag( - byFlagFallbacks: Record - ): Record { - const sanitizedByFlag: Record = {}; + byFlagFallbacks: Record + ): Record { + const sanitizedByFlag: Record = {}; const entries = Object.entries(byFlagFallbacks); entries.forEach(([flag, t]) => { diff --git a/src/evaluator/fallbackTreatmentsCalculator/index.ts b/src/evaluator/fallbackTreatmentsCalculator/index.ts new file mode 100644 index 00000000..30ef69e2 --- /dev/null +++ b/src/evaluator/fallbackTreatmentsCalculator/index.ts @@ -0,0 +1,49 @@ +import { FallbackTreatmentConfiguration, FallbackTreatment, IFallbackTreatmentsCalculator} from '../../../types/splitio'; + +export class FallbackTreatmentsCalculator implements IFallbackTreatmentsCalculator { + private readonly labelPrefix = 'fallback - '; + private readonly control = 'CONTROL'; + private readonly fallbacks: FallbackTreatmentConfiguration; + + constructor(fallbacks: FallbackTreatmentConfiguration) { + this.fallbacks = fallbacks; + } + + resolve(flagName: string, label?: string | undefined): FallbackTreatment { + 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: this.control, + config: null, + label: this.resolveLabel(label), + }; + } + + private copyWithLabel(fallback: FallbackTreatment, label: string | undefined): FallbackTreatment { + if (typeof fallback === 'string') { + return { + treatment: fallback, + config: null, + label: this.resolveLabel(label), + }; + } + + return { + treatment: fallback.treatment, + config: fallback.config, + label: this.resolveLabel(label), + }; + } + + private resolveLabel(label?: string | undefined): string | undefined { + return label ? `${this.labelPrefix}${label}` : undefined; + } + +} diff --git a/types/splitio.d.ts b/types/splitio.d.ts index 788029d3..f1ac014f 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -624,7 +624,7 @@ declare namespace SplitIO { /** * Fallback treatments to be used when the SDK is not ready or the flag is not found. */ - readonly fallbackTreatments?: FallbackTreatmentOptions; + readonly fallbackTreatments?: FallbackTreatmentConfiguration; } /** * Log levels. @@ -1232,15 +1232,26 @@ declare namespace SplitIO { * User consent status. */ type ConsentStatus = 'GRANTED' | 'DECLINED' | 'UNKNOWN'; + /** + * Fallback treatment can be either a string (treatment) or an object with treatment, config and label. + */ + type FallbackTreatment = string | { + treatment: string; + config?: string | null; + label?: string | null; + }; /** * Fallback treatments to be used when the SDK is not ready or the flag is not found. */ - type FallbackTreatmentOptions = { - global?: TreatmentWithConfig | Treatment, + type FallbackTreatmentConfiguration = { + global?: FallbackTreatment, byFlag: { - [key: string]: TreatmentWithConfig | Treatment + [key: string]: FallbackTreatment } } + type IFallbackTreatmentsCalculator = { + resolve(flagName: string, label?: string | undefined): FallbackTreatment; + } /** * Logger. Its interface details are not part of the public API. It shouldn't be used directly. */ From 53cc6dbb7ff6249a12e7b5384e9a24785178d20b Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Tue, 21 Oct 2025 11:52:00 -0300 Subject: [PATCH 07/35] feat: add whenReady and whenReadyFromCache methods to replace deprecated ready method --- src/readiness/sdkReadinessManager.ts | 30 ++++++++++++++++++++++++++- types/splitio.d.ts | 31 +++++++++++++++++++++------- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/src/readiness/sdkReadinessManager.ts b/src/readiness/sdkReadinessManager.ts index 3f3de706..2bda4ed3 100644 --- a/src/readiness/sdkReadinessManager.ts +++ b/src/readiness/sdkReadinessManager.ts @@ -9,6 +9,7 @@ import { ERROR_CLIENT_LISTENER, CLIENT_READY_FROM_CACHE, CLIENT_READY, CLIENT_NO const NEW_LISTENER_EVENT = 'newListener'; const REMOVE_LISTENER_EVENT = 'removeListener'; +const TIMEOUT_ERROR = new Error('Split SDK has emitted SDK_READY_TIMED_OUT event.'); /** * SdkReadinessManager factory, which provides the public status API of SDK clients and manager: ready promise, readiness event emitter and constants (SDK_READY, etc). @@ -93,10 +94,11 @@ export function sdkReadinessManagerFactory( SDK_READY_TIMED_OUT, }, + // @TODO: remove in next major ready() { if (readinessManager.hasTimedout()) { if (!readinessManager.isReady()) { - return promiseWrapper(Promise.reject(new Error('Split SDK has emitted SDK_READY_TIMED_OUT event.')), defaultOnRejected); + return promiseWrapper(Promise.reject(TIMEOUT_ERROR), defaultOnRejected); } else { return Promise.resolve(); } @@ -104,6 +106,32 @@ export function sdkReadinessManagerFactory( return readyPromise; }, + whenReady() { + return new Promise((resolve, reject) => { + if (readinessManager.isReady()) { + resolve(); + } else if (readinessManager.hasTimedout()) { + reject(TIMEOUT_ERROR); + } else { + readinessManager.gate.once(SDK_READY, resolve); + readinessManager.gate.once(SDK_READY_TIMED_OUT, () => reject(TIMEOUT_ERROR)); + } + }); + }, + + whenReadyFromCache() { + return new Promise((resolve, reject) => { + if (readinessManager.isReadyFromCache()) { + resolve(); + } else if (readinessManager.hasTimedout()) { + reject(TIMEOUT_ERROR); + } else { + readinessManager.gate.once(SDK_READY_FROM_CACHE, resolve); + readinessManager.gate.once(SDK_READY_TIMED_OUT, () => reject(TIMEOUT_ERROR)); + } + }); + }, + getStatus() { return { isReady: readinessManager.isReady(), diff --git a/types/splitio.d.ts b/types/splitio.d.ts index 4a473fd8..58aca849 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -525,19 +525,19 @@ declare namespace SplitIO { */ type EventConsts = { /** - * The ready event. + * The ready event emitted once the SDK is ready to evaluate feature flags with cache synchronized with the backend. */ SDK_READY: 'init::ready'; /** - * The ready event when fired with cached data. + * The ready event emitted once the SDK is ready to evaluate feature flags with cache that could be stale. Use SDK_READY if you want to be sure the cache is in sync with the backend. */ SDK_READY_FROM_CACHE: 'init::cache-ready'; /** - * The timeout event. + * The timeout event emitted after `startup.readyTimeout` seconds if the SDK_READY event was not emitted. */ SDK_READY_TIMED_OUT: 'init::timeout'; /** - * The update event. + * The update event emitted when the SDK cache is updated with new data from the backend. */ SDK_UPDATE: 'state::update'; }; @@ -704,7 +704,7 @@ declare namespace SplitIO { /** * `isReadyFromCache` indicates if the client has triggered an `SDK_READY_FROM_CACHE` event and - * thus is ready to evaluate with cached data, although the data in cache might be stale. + * thus is ready to evaluate with cached data, although the data in cache might be stale, not synchronized with the backend. */ isReadyFromCache: boolean; @@ -728,7 +728,7 @@ declare namespace SplitIO { /** * `isOperational` indicates if the client can evaluate feature flags. * In this state, `getTreatment` calls will not return `CONTROL` due to the SDK being unready or destroyed. - * It's equivalent to `(isReady || isReadyFromCache) && !isDestroyed`. + * It's equivalent to `isReadyFromCache && !isDestroyed`. */ isOperational: boolean; @@ -752,7 +752,7 @@ declare namespace SplitIO { */ getStatus(): ReadinessStatus; /** - * Returns a promise that resolves once the SDK has finished loading (`SDK_READY` event emitted) or rejected if the SDK has timedout (`SDK_READY_TIMED_OUT` event emitted). + * Returns a promise that resolves once the SDK has finished synchronizing with the backend (`SDK_READY` event emitted) or rejected if the SDK has timedout (`SDK_READY_TIMED_OUT` event emitted). * As it's meant to provide similar flexibility to the event approach, given that the SDK might be eventually ready after a timeout event, the `ready` method will return a resolved promise once the SDK is ready. * * Caveats: the method was designed to avoid an unhandled Promise rejection if the rejection case is not handled, so that `onRejected` handler is optional when using promises. @@ -767,8 +767,23 @@ declare namespace SplitIO { * ``` * * @returns A promise that resolves once the SDK is ready or rejects if the SDK has timedout. + * @deprecated Use `whenReady` instead. */ ready(): Promise; + /** + * Returns a promise that resolves once the SDK is ready for evaluations using cached data synchronized with the backend (`SDK_READY` event emitted) or rejected if the SDK has timedout (`SDK_READY_TIMED_OUT` event emitted). + * As it's meant to provide similar flexibility to the event approach, given that the SDK might be eventually ready after a timeout event, the `whenReady` method will return a resolved promise once the SDK is ready. + * + * @returns A promise that resolves once the SDK is ready or rejects if the SDK has timedout. + */ + whenReady(): Promise; + /** + * Returns a promise that resolves once the SDK is ready for evaluations using cached data which might not yet be synchronized with the backend (`SDK_READY_FROM_CACHE` event emitted) or rejected if the SDK has timedout (`SDK_READY_TIMED_OUT` event emitted). + * As it's meant to provide similar flexibility to the event approach, given that the SDK might be eventually ready from cache after a timeout event, the `whenReadyFromCache` method will return a resolved promise once the SDK is ready from cache. + * + * @returns A promise that resolves once the SDK is ready from cache or rejects if the SDK has timedout. + */ + whenReadyFromCache(): Promise; } /** * Common definitions between clients for different environments interface. @@ -1702,7 +1717,7 @@ declare namespace SplitIO { * Wait for the SDK client to be ready before calling this method. * * ```js - * await factory.client().ready(); + * await factory.client().whenReady(); * const rolloutPlan = factory.getRolloutPlan(); * ``` * From 8b6a8d552e8381e560ed4b5e374c0a005f4891c4 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Wed, 22 Oct 2025 12:21:23 -0300 Subject: [PATCH 08/35] refactor: simplify SDK readiness checks --- src/logger/constants.ts | 2 +- src/logger/messages/warn.ts | 2 +- .../__tests__/clientInputValidation.spec.ts | 2 +- src/sdkClient/client.ts | 6 ++-- src/sdkClient/clientInputValidation.ts | 4 +-- .../__tests__/index.asyncCache.spec.ts | 5 ++-- .../__tests__/index.syncCache.spec.ts | 3 +- src/sdkManager/index.ts | 8 ++--- .../__tests__/isOperational.spec.ts | 30 ++++++------------- src/utils/inputValidation/index.ts | 2 +- src/utils/inputValidation/isOperational.ts | 13 +++++--- src/utils/inputValidation/splitExistence.ts | 4 +-- 12 files changed, 38 insertions(+), 43 deletions(-) diff --git a/src/logger/constants.ts b/src/logger/constants.ts index de1ebe58..ca331f82 100644 --- a/src/logger/constants.ts +++ b/src/logger/constants.ts @@ -60,7 +60,7 @@ export const SUBMITTERS_PUSH_PAGE_HIDDEN = 125; export const ENGINE_VALUE_INVALID = 200; export const ENGINE_VALUE_NO_ATTRIBUTES = 201; export const CLIENT_NO_LISTENER = 202; -export const CLIENT_NOT_READY = 203; +export const CLIENT_NOT_READY_FROM_CACHE = 203; export const SYNC_MYSEGMENTS_FETCH_RETRY = 204; export const SYNC_SPLITS_FETCH_FAILS = 205; export const STREAMING_PARSING_ERROR_FAILS = 206; diff --git a/src/logger/messages/warn.ts b/src/logger/messages/warn.ts index 81cfda1a..8f87babd 100644 --- a/src/logger/messages/warn.ts +++ b/src/logger/messages/warn.ts @@ -14,7 +14,7 @@ export const codesWarn: [number, string][] = codesError.concat([ [c.SUBMITTERS_PUSH_FAILS, c.LOG_PREFIX_SYNC_SUBMITTERS + 'Dropping %s after retry. Reason: %s.'], [c.SUBMITTERS_PUSH_RETRY, c.LOG_PREFIX_SYNC_SUBMITTERS + 'Failed to push %s, keeping data to retry on next iteration. Reason: %s.'], // client status - [c.CLIENT_NOT_READY, '%s: the SDK is not ready, results may be incorrect%s. Make sure to wait for SDK readiness before using this method.'], + [c.CLIENT_NOT_READY_FROM_CACHE, '%s: the SDK is not ready to evaluate. Results may be incorrect%s. Make sure to wait for SDK readiness before using this method.'], [c.CLIENT_NO_LISTENER, 'No listeners for SDK Readiness detected. Incorrect control treatments could have been logged if you called getTreatment/s while the SDK was not yet ready.'], // input validation [c.WARN_SETTING_NULL, '%s: Property "%s" is of invalid type. Setting value to null.'], diff --git a/src/sdkClient/__tests__/clientInputValidation.spec.ts b/src/sdkClient/__tests__/clientInputValidation.spec.ts index f70845f7..452949e8 100644 --- a/src/sdkClient/__tests__/clientInputValidation.spec.ts +++ b/src/sdkClient/__tests__/clientInputValidation.spec.ts @@ -14,7 +14,7 @@ const EVALUATION_RESULT = 'on'; const client: any = createClientMock(EVALUATION_RESULT); const readinessManager: any = { - isReady: () => true, + isReadyFromCache: () => true, isDestroyed: () => false }; diff --git a/src/sdkClient/client.ts b/src/sdkClient/client.ts index 0e526f72..2e431dc8 100644 --- a/src/sdkClient/client.ts +++ b/src/sdkClient/client.ts @@ -51,7 +51,7 @@ export function clientFactory(params: ISdkFactoryContext): SplitIO.IClient | Spl return treatment; }; - const evaluation = readinessManager.isReady() || readinessManager.isReadyFromCache() ? + const evaluation = readinessManager.isReadyFromCache() ? evaluateFeature(log, key, featureFlagName, attributes, storage) : isAsync ? // If the SDK is not ready, treatment may be incorrect due to having splits but not segments data, or storage is not connected Promise.resolve(treatmentNotReady) : @@ -80,7 +80,7 @@ export function clientFactory(params: ISdkFactoryContext): SplitIO.IClient | Spl return treatments; }; - const evaluations = readinessManager.isReady() || readinessManager.isReadyFromCache() ? + const evaluations = readinessManager.isReadyFromCache() ? evaluateFeatures(log, key, featureFlagNames, attributes, storage) : isAsync ? // If the SDK is not ready, treatment may be incorrect due to having splits but not segments data, or storage is not connected Promise.resolve(treatmentsNotReady(featureFlagNames)) : @@ -109,7 +109,7 @@ export function clientFactory(params: ISdkFactoryContext): SplitIO.IClient | Spl return treatments; }; - const evaluations = readinessManager.isReady() || readinessManager.isReadyFromCache() ? + const evaluations = readinessManager.isReadyFromCache() ? evaluateFeaturesByFlagSets(log, key, flagSetNames, attributes, storage, methodName) : isAsync ? Promise.resolve({}) : diff --git a/src/sdkClient/clientInputValidation.ts b/src/sdkClient/clientInputValidation.ts index 40765d41..b67025d7 100644 --- a/src/sdkClient/clientInputValidation.ts +++ b/src/sdkClient/clientInputValidation.ts @@ -9,7 +9,7 @@ import { validateSplits, validateTrafficType, validateIfNotDestroyed, - validateIfOperational, + validateIfReadyFromCache, validateEvaluationOptions } from '../utils/inputValidation'; import { startsWith } from '../utils/lang'; @@ -46,7 +46,7 @@ export function clientInputValidationDecorator true), + isReadyFromCache: jest.fn(() => true), isDestroyed: jest.fn(() => false) }, sdkStatus: jest.fn() @@ -77,7 +78,7 @@ describe('Manager with async cache', () => { const cache = new SplitsCachePluggable(loggerMock, keys, wrapperAdapter(loggerMock, {})); const manager = sdkManagerFactory({ mode: 'consumer_partial', log: loggerMock }, cache, sdkReadinessManagerMock); - expect(await manager.split('some_spplit')).toEqual(null); + expect(await manager.split('some_split')).toEqual(null); expect(await manager.splits()).toEqual([]); expect(await manager.names()).toEqual([]); @@ -98,7 +99,7 @@ describe('Manager with async cache', () => { const manager = sdkManagerFactory({ mode: 'consumer_partial', log: loggerMock }, {}, sdkReadinessManagerMock) as SplitIO.IAsyncManager; function validateManager() { - expect(manager.split('some_spplit')).resolves.toBe(null); + expect(manager.split('some_split')).resolves.toBe(null); expect(manager.splits()).resolves.toEqual([]); expect(manager.names()).resolves.toEqual([]); } diff --git a/src/sdkManager/__tests__/index.syncCache.spec.ts b/src/sdkManager/__tests__/index.syncCache.spec.ts index 391a053c..3437f008 100644 --- a/src/sdkManager/__tests__/index.syncCache.spec.ts +++ b/src/sdkManager/__tests__/index.syncCache.spec.ts @@ -9,6 +9,7 @@ import { loggerMock } from '../../logger/__tests__/sdkLogger.mock'; const sdkReadinessManagerMock = { readinessManager: { isReady: jest.fn(() => true), + isReadyFromCache: jest.fn(() => true), isDestroyed: jest.fn(() => false) }, sdkStatus: jest.fn() @@ -62,7 +63,7 @@ describe('Manager with sync cache (In Memory)', () => { sdkReadinessManagerMock.readinessManager.isDestroyed = () => true; function validateManager() { - expect(manager.split('some_spplit')).toBe(null); + expect(manager.split('some_split')).toBe(null); expect(manager.splits()).toEqual([]); expect(manager.names()).toEqual([]); } diff --git a/src/sdkManager/index.ts b/src/sdkManager/index.ts index d241b82e..5260170c 100644 --- a/src/sdkManager/index.ts +++ b/src/sdkManager/index.ts @@ -1,7 +1,7 @@ import { objectAssign } from '../utils/lang/objectAssign'; import { thenable } from '../utils/promise/thenable'; import { find } from '../utils/lang'; -import { validateSplit, validateSplitExistence, validateIfNotDestroyed, validateIfOperational } from '../utils/inputValidation'; +import { validateSplit, validateSplitExistence, validateIfOperational } from '../utils/inputValidation'; import { ISplitsCacheAsync, ISplitsCacheSync } from '../storages/types'; import { ISdkReadinessManager } from '../readiness/types'; import { ISplit } from '../dtos/types'; @@ -66,7 +66,7 @@ export function sdkManagerFactory { @@ -28,37 +28,25 @@ describe('validateIfNotDestroyed', () => { }); }); -describe('validateIfOperational', () => { - - test('Should return true and log nothing if the SDK was ready.', () => { - const readinessManagerMock = { isReady: jest.fn(() => true) }; - - // @ts-ignore - expect(validateIfOperational(loggerMock, readinessManagerMock, 'test_method')).toBe(true); // It should return true if SDK was ready. - expect(readinessManagerMock.isReady).toBeCalledTimes(1); // It checks for readiness status using the context. - expect(loggerMock.warn).not.toBeCalled(); // But it should not log any warnings. - expect(loggerMock.error).not.toBeCalled(); // But it should not log any errors. - }); +describe('validateIfReadyFromCache', () => { test('Should return true and log nothing if the SDK was ready from cache.', () => { - const readinessManagerMock = { isReady: jest.fn(() => false), isReadyFromCache: jest.fn(() => true) }; + const readinessManagerMock = { isReadyFromCache: jest.fn(() => true) }; // @ts-ignore - expect(validateIfOperational(loggerMock, readinessManagerMock, 'test_method')).toBe(true); // It should return true if SDK was ready. - expect(readinessManagerMock.isReady).toBeCalledTimes(1); // It checks for SDK_READY status. + expect(validateIfReadyFromCache(loggerMock, readinessManagerMock, 'test_method')).toBe(true); // It should return true if SDK was ready. expect(readinessManagerMock.isReadyFromCache).toBeCalledTimes(1); // It checks for SDK_READY_FROM_CACHE status. expect(loggerMock.warn).not.toBeCalled(); // But it should not log any warnings. expect(loggerMock.error).not.toBeCalled(); // But it should not log any errors. }); - test('Should return false and log a warning if the SDK was not ready.', () => { - const readinessManagerMock = { isReady: jest.fn(() => false), isReadyFromCache: jest.fn(() => false) }; + test('Should return false and log a warning if the SDK was not ready from cache.', () => { + const readinessManagerMock = { isReadyFromCache: jest.fn(() => false) }; // @ts-ignore - expect(validateIfOperational(loggerMock, readinessManagerMock, 'test_method')).toBe(false); // It should return true if SDK was ready. - expect(readinessManagerMock.isReady).toBeCalledTimes(1); // It checks for SDK_READY status. + expect(validateIfReadyFromCache(loggerMock, readinessManagerMock, 'test_method')).toBe(false); // It should return true if SDK was ready. expect(readinessManagerMock.isReadyFromCache).toBeCalledTimes(1); // It checks for SDK_READY_FROM_CACHE status. - expect(loggerMock.warn).toBeCalledWith(CLIENT_NOT_READY, ['test_method', '']); // It should log the expected warning. + expect(loggerMock.warn).toBeCalledWith(CLIENT_NOT_READY_FROM_CACHE, ['test_method', '']); // It should log the expected warning. expect(loggerMock.error).not.toBeCalled(); // But it should not log any errors. }); }); diff --git a/src/utils/inputValidation/index.ts b/src/utils/inputValidation/index.ts index eac9777d..f6e06c5e 100644 --- a/src/utils/inputValidation/index.ts +++ b/src/utils/inputValidation/index.ts @@ -7,7 +7,7 @@ export { validateKey } from './key'; export { validateSplit } from './split'; export { validateSplits } from './splits'; export { validateTrafficType } from './trafficType'; -export { validateIfNotDestroyed, validateIfOperational } from './isOperational'; +export { validateIfNotDestroyed, validateIfReadyFromCache, validateIfOperational } from './isOperational'; export { validateSplitExistence } from './splitExistence'; export { validateTrafficTypeExistence } from './trafficTypeExistence'; export { validateEvaluationOptions } from './eventProperties'; diff --git a/src/utils/inputValidation/isOperational.ts b/src/utils/inputValidation/isOperational.ts index 3d990433..5f122926 100644 --- a/src/utils/inputValidation/isOperational.ts +++ b/src/utils/inputValidation/isOperational.ts @@ -1,4 +1,4 @@ -import { ERROR_CLIENT_DESTROYED, CLIENT_NOT_READY } from '../../logger/constants'; +import { ERROR_CLIENT_DESTROYED, CLIENT_NOT_READY_FROM_CACHE } from '../../logger/constants'; import { ILogger } from '../../logger/types'; import { IReadinessManager } from '../../readiness/types'; @@ -9,9 +9,14 @@ export function validateIfNotDestroyed(log: ILogger, readinessManager: IReadines return false; } -export function validateIfOperational(log: ILogger, readinessManager: IReadinessManager, method: string, featureFlagNameOrNames?: string | string[] | false) { - if (readinessManager.isReady() || readinessManager.isReadyFromCache()) return true; +export function validateIfReadyFromCache(log: ILogger, readinessManager: IReadinessManager, method: string, featureFlagNameOrNames?: string | string[] | false) { + if (readinessManager.isReadyFromCache()) return true; - log.warn(CLIENT_NOT_READY, [method, featureFlagNameOrNames ? ` for feature flag ${featureFlagNameOrNames.toString()}` : '']); + log.warn(CLIENT_NOT_READY_FROM_CACHE, [method, featureFlagNameOrNames ? ` for feature flag ${featureFlagNameOrNames.toString()}` : '']); return false; } + +// Operational means that the SDK is ready to evaluate (not destroyed and ready from cache) +export function validateIfOperational(log: ILogger, readinessManager: IReadinessManager, method: string, featureFlagNameOrNames?: string | string[] | false) { + return validateIfNotDestroyed(log, readinessManager, method) && validateIfReadyFromCache(log, readinessManager, method, featureFlagNameOrNames); +} diff --git a/src/utils/inputValidation/splitExistence.ts b/src/utils/inputValidation/splitExistence.ts index 2f3105f9..60ac3743 100644 --- a/src/utils/inputValidation/splitExistence.ts +++ b/src/utils/inputValidation/splitExistence.ts @@ -5,10 +5,10 @@ import { WARN_NOT_EXISTENT_SPLIT } from '../../logger/constants'; /** * This is defined here and in this format mostly because of the logger and the fact that it's considered a validation at product level. - * But it's not going to run on the input validation layer. In any case, the most compeling reason to use it as we do is to avoid going to Redis and get a split twice. + * But it's not going to run on the input validation layer. In any case, the most compelling reason to use it as we do is to avoid going to Redis and get a split twice. */ export function validateSplitExistence(log: ILogger, readinessManager: IReadinessManager, splitName: string, labelOrSplitObj: any, method: string): boolean { - if (readinessManager.isReady()) { // Only if it's ready we validate this, otherwise it may just be that the SDK is not ready yet. + 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) { log.warn(WARN_NOT_EXISTENT_SPLIT, [method, splitName]); return false; From 13eaec3e8026397d116e65a25b919aa606ef33be Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Wed, 22 Oct 2025 12:40:13 -0300 Subject: [PATCH 09/35] feat: update whenReadyFromCache to return boolean indicating SDK ready state --- src/readiness/sdkReadinessManager.ts | 6 +++--- types/splitio.d.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/readiness/sdkReadinessManager.ts b/src/readiness/sdkReadinessManager.ts index 2bda4ed3..03afd873 100644 --- a/src/readiness/sdkReadinessManager.ts +++ b/src/readiness/sdkReadinessManager.ts @@ -120,13 +120,13 @@ export function sdkReadinessManagerFactory( }, whenReadyFromCache() { - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { if (readinessManager.isReadyFromCache()) { - resolve(); + resolve(readinessManager.isReady()); } else if (readinessManager.hasTimedout()) { reject(TIMEOUT_ERROR); } else { - readinessManager.gate.once(SDK_READY_FROM_CACHE, resolve); + readinessManager.gate.once(SDK_READY_FROM_CACHE, () => resolve(readinessManager.isReady())); readinessManager.gate.once(SDK_READY_TIMED_OUT, () => reject(TIMEOUT_ERROR)); } }); diff --git a/types/splitio.d.ts b/types/splitio.d.ts index 58aca849..f3981076 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -781,9 +781,9 @@ declare namespace SplitIO { * Returns a promise that resolves once the SDK is ready for evaluations using cached data which might not yet be synchronized with the backend (`SDK_READY_FROM_CACHE` event emitted) or rejected if the SDK has timedout (`SDK_READY_TIMED_OUT` event emitted). * As it's meant to provide similar flexibility to the event approach, given that the SDK might be eventually ready from cache after a timeout event, the `whenReadyFromCache` method will return a resolved promise once the SDK is ready from cache. * - * @returns A promise that resolves once the SDK is ready from cache or rejects if the SDK has timedout. + * @returns A promise that resolves once the SDK is ready from cache or rejects if the SDK has timedout. The promise resolves with a boolean value that indicates whether the SDK is ready (synchronized with the backend) or not. */ - whenReadyFromCache(): Promise; + whenReadyFromCache(): Promise; } /** * Common definitions between clients for different environments interface. From e2179d7f2da0407ea20bc70140da21d55c414ebb Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Wed, 22 Oct 2025 15:32:39 -0300 Subject: [PATCH 10/35] Polishing --- src/readiness/readinessManager.ts | 4 ++-- types/splitio.d.ts | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/readiness/readinessManager.ts b/src/readiness/readinessManager.ts index 8a93d03c..319e843d 100644 --- a/src/readiness/readinessManager.ts +++ b/src/readiness/readinessManager.ts @@ -90,7 +90,7 @@ export function readinessManagerFactory( if (!isReady && !isDestroyed) { try { syncLastUpdate(); - gate.emit(SDK_READY_FROM_CACHE); + gate.emit(SDK_READY_FROM_CACHE, isReady); } catch (e) { // throws user callback exceptions in next tick setTimeout(() => { throw e; }, 0); @@ -116,7 +116,7 @@ export function readinessManagerFactory( syncLastUpdate(); if (!isReadyFromCache) { isReadyFromCache = true; - gate.emit(SDK_READY_FROM_CACHE); + gate.emit(SDK_READY_FROM_CACHE, isReady); } gate.emit(SDK_READY); } catch (e) { diff --git a/types/splitio.d.ts b/types/splitio.d.ts index f3981076..b3884694 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -772,16 +772,17 @@ declare namespace SplitIO { ready(): Promise; /** * Returns a promise that resolves once the SDK is ready for evaluations using cached data synchronized with the backend (`SDK_READY` event emitted) or rejected if the SDK has timedout (`SDK_READY_TIMED_OUT` event emitted). - * As it's meant to provide similar flexibility to the event approach, given that the SDK might be eventually ready after a timeout event, the `whenReady` method will return a resolved promise once the SDK is ready. + * As it's meant to provide similar flexibility than event listeners, given that the SDK might be ready after a timeout event, the `whenReady` method will return a resolved promise once the SDK is ready. * - * @returns A promise that resolves once the SDK is ready or rejects if the SDK has timedout. + * @returns A promise that resolves once the SDK_READY event is emitted or rejects if the SDK has timedout. */ whenReady(): Promise; /** * Returns a promise that resolves once the SDK is ready for evaluations using cached data which might not yet be synchronized with the backend (`SDK_READY_FROM_CACHE` event emitted) or rejected if the SDK has timedout (`SDK_READY_TIMED_OUT` event emitted). - * As it's meant to provide similar flexibility to the event approach, given that the SDK might be eventually ready from cache after a timeout event, the `whenReadyFromCache` method will return a resolved promise once the SDK is ready from cache. + * As it's meant to provide similar flexibility than event listeners, given that the SDK might be ready from cache after a timeout event, the `whenReadyFromCache` method will return a resolved promise once the SDK is ready from cache. * - * @returns A promise that resolves once the SDK is ready from cache or rejects if the SDK has timedout. The promise resolves with a boolean value that indicates whether the SDK is ready (synchronized with the backend) or not. + * @returns A promise that resolves once the SDK_READY_FROM_CACHE event is emitted or rejects if the SDK has timedout. The promise resolves with a boolean value that + * indicates whether the SDK_READY_FROM_CACHE event was emitted together with the SDK_READY event (i.e., the SDK is ready and synchronized with the backend) or not. */ whenReadyFromCache(): Promise; } From 89111320ffdeddd45e1bd7a738c9e294bda60bdb Mon Sep 17 00:00:00 2001 From: Emmanuel Zamora Date: Wed, 22 Oct 2025 16:43:38 -0300 Subject: [PATCH 11/35] [FME-10567] Add fallbackTreatmentCalculator to client --- .../__tests__/fallback-calculator.spec.ts | 106 ++++++++++++++++-- .../__tests__/fallback-sanitizer.spec.ts | 23 ++-- .../fallbackSanitizer/index.ts | 44 ++++---- .../fallbackTreatmentsCalculator/index.ts | 36 ++++-- .../__tests__/clientInputValidation.spec.ts | 5 +- .../__tests__/sdkClientMethod.spec.ts | 7 +- src/sdkClient/client.ts | 12 +- src/sdkClient/clientInputValidation.ts | 29 +++-- src/sdkClient/sdkClient.ts | 3 +- src/sdkFactory/__tests__/index.spec.ts | 2 + src/sdkFactory/index.ts | 5 +- src/sdkFactory/types.ts | 2 + types/splitio.d.ts | 17 +-- 13 files changed, 206 insertions(+), 85 deletions(-) diff --git a/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts index 786ecbe8..4a1e9d93 100644 --- a/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts +++ b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts @@ -1,7 +1,91 @@ import { FallbackTreatmentsCalculator } from '../'; -import type { FallbackTreatmentConfiguration } from '../../../../types/splitio'; // adjust path if needed +import type { FallbackTreatmentConfiguration } from '../../../../types/splitio'; +import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock'; +import { CONTROL } from '../../../utils/constants'; -describe('FallbackTreatmentsCalculator', () => { +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 = { @@ -9,7 +93,7 @@ describe('FallbackTreatmentsCalculator', () => { 'featureA': { treatment: 'TREATMENT_A', config: '{ value: 1 }' }, }, }; - const calculator = new FallbackTreatmentsCalculator(config); + const calculator = new FallbackTreatmentsCalculator(loggerMock, config); const result = calculator.resolve('featureA', 'label by flag'); expect(result).toEqual({ @@ -24,7 +108,7 @@ describe('FallbackTreatmentsCalculator', () => { byFlag: {}, global: { treatment: 'GLOBAL_TREATMENT', config: '{ global: true }' }, }; - const calculator = new FallbackTreatmentsCalculator(config); + const calculator = new FallbackTreatmentsCalculator(loggerMock, config); const result = calculator.resolve('missingFlag', 'label by global'); expect(result).toEqual({ @@ -38,29 +122,29 @@ describe('FallbackTreatmentsCalculator', () => { const config: FallbackTreatmentConfiguration = { byFlag: {}, }; - const calculator = new FallbackTreatmentsCalculator(config); + const calculator = new FallbackTreatmentsCalculator(loggerMock, config); const result = calculator.resolve('missingFlag', 'label by noFallback'); expect(result).toEqual({ - treatment: 'CONTROL', + treatment: CONTROL, config: null, - label: 'fallback - label by noFallback', + label: 'label by noFallback', }); }); test('returns undefined label if no label provided', () => { const config: FallbackTreatmentConfiguration = { byFlag: { - 'featureB': { treatment: 'TREATMENT_B' }, + 'featureB': { treatment: 'TREATMENT_B', config: '{ value: 1 }' }, }, }; - const calculator = new FallbackTreatmentsCalculator(config); + const calculator = new FallbackTreatmentsCalculator(loggerMock, config); const result = calculator.resolve('featureB'); expect(result).toEqual({ treatment: 'TREATMENT_B', - config: undefined, - label: undefined, + config: '{ value: 1 }', + label: '', }); }); }); diff --git a/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts index 0f314e2a..aaaf106c 100644 --- a/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts +++ b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts @@ -1,5 +1,6 @@ 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"}' }; @@ -10,7 +11,7 @@ describe('FallbacksSanitizer', () => { }); afterEach(() => { - (console.error as jest.Mock).mockRestore(); + (loggerMock.error as jest.Mock).mockRestore(); }); describe('isValidFlagName', () => { @@ -52,14 +53,14 @@ describe('FallbacksSanitizer', () => { describe('sanitizeGlobal', () => { test('returns the treatment if valid', () => { - expect(FallbacksSanitizer.sanitizeGlobal(validTreatment)).toEqual(validTreatment); - expect(console.error).not.toHaveBeenCalled(); + expect(FallbacksSanitizer.sanitizeGlobal(loggerMock, validTreatment)).toEqual(validTreatment); + expect(loggerMock.error).not.toHaveBeenCalled(); }); test('returns undefined and logs error if invalid', () => { - const result = FallbacksSanitizer.sanitizeGlobal(invalidTreatment); + const result = FallbacksSanitizer.sanitizeGlobal(loggerMock, invalidTreatment); expect(result).toBeUndefined(); - expect(console.error).toHaveBeenCalledWith( + expect(loggerMock.error).toHaveBeenCalledWith( expect.stringContaining('Fallback treatments - Discarded fallback') ); }); @@ -73,10 +74,10 @@ describe('FallbacksSanitizer', () => { bad_treatment: invalidTreatment, }; - const result = FallbacksSanitizer.sanitizeByFlag(input); + const result = FallbacksSanitizer.sanitizeByFlag(loggerMock, input); expect(result).toEqual({ valid_flag: validTreatment }); - expect(console.error).toHaveBeenCalledTimes(2); // invalid flag + bad_treatment + expect(loggerMock.error).toHaveBeenCalledTimes(2); // invalid flag + bad_treatment }); test('returns empty object if all invalid', () => { @@ -84,9 +85,9 @@ describe('FallbacksSanitizer', () => { 'invalid flag': invalidTreatment, }; - const result = FallbacksSanitizer.sanitizeByFlag(input); + const result = FallbacksSanitizer.sanitizeByFlag(loggerMock, input); expect(result).toEqual({}); - expect(console.error).toHaveBeenCalled(); + expect(loggerMock.error).toHaveBeenCalled(); }); test('returns same object if all valid', () => { @@ -95,9 +96,9 @@ describe('FallbacksSanitizer', () => { flag_two: { treatment: 'valid_2', config: null }, }; - const result = FallbacksSanitizer.sanitizeByFlag(input); + const result = FallbacksSanitizer.sanitizeByFlag(loggerMock, input); expect(result).toEqual(input); - expect(console.error).not.toHaveBeenCalled(); + expect(loggerMock.error).not.toHaveBeenCalled(); }); }); }); diff --git a/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts b/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts index 06c7fe2e..d7996bbb 100644 --- a/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts +++ b/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts @@ -1,4 +1,6 @@ -import { FallbackTreatment } from '../../../../types/splitio'; +import { FallbackTreatment, Treatment, TreatmentWithConfig } from '../../../../types/splitio'; +import { ILogger } from '../../../logger/types'; +import { isObject, isString } from '../../../utils/lang'; import { FallbackDiscardReason } from '../constants'; @@ -10,51 +12,43 @@ export class FallbacksSanitizer { return name.length <= 100 && !name.includes(' '); } - private static isValidTreatment(t?: FallbackTreatment): boolean { - if (!t) { - return false; - } - - if (typeof t === 'string') { - if (t.length > 100) { - return false; - } - return FallbacksSanitizer.pattern.test(t); - } + private static isValidTreatment(t?: Treatment | FallbackTreatment): boolean { + const treatment = isObject(t) ? (t as TreatmentWithConfig).treatment : t; - const { treatment } = t; - if (!treatment || treatment.length > 100) { + if (!isString(treatment) || treatment.length > 100) { return false; } return FallbacksSanitizer.pattern.test(treatment); } - static sanitizeGlobal(treatment?: FallbackTreatment): FallbackTreatment | undefined { + static sanitizeGlobal(logger: ILogger, treatment?: string | FallbackTreatment): string | FallbackTreatment | undefined { if (!this.isValidTreatment(treatment)) { - console.error( + logger.error( `Fallback treatments - Discarded fallback: ${FallbackDiscardReason.Treatment}` ); return undefined; } - return treatment!; + return treatment; } static sanitizeByFlag( - byFlagFallbacks: Record - ): Record { - const sanitizedByFlag: Record = {}; - - const entries = Object.entries(byFlagFallbacks); - entries.forEach(([flag, t]) => { + logger: ILogger, + byFlagFallbacks: Record + ): Record { + const sanitizedByFlag: Record = {}; + + const entries = Object.keys(byFlagFallbacks); + entries.forEach((flag) => { + const t = byFlagFallbacks[flag]; if (!this.isValidFlagName(flag)) { - console.error( + logger.error( `Fallback treatments - Discarded flag '${flag}': ${FallbackDiscardReason.FlagName}` ); return; } if (!this.isValidTreatment(t)) { - console.error( + logger.error( `Fallback treatments - Discarded treatment for flag '${flag}': ${FallbackDiscardReason.Treatment}` ); return; diff --git a/src/evaluator/fallbackTreatmentsCalculator/index.ts b/src/evaluator/fallbackTreatmentsCalculator/index.ts index 30ef69e2..521b9419 100644 --- a/src/evaluator/fallbackTreatmentsCalculator/index.ts +++ b/src/evaluator/fallbackTreatmentsCalculator/index.ts @@ -1,16 +1,28 @@ -import { FallbackTreatmentConfiguration, FallbackTreatment, IFallbackTreatmentsCalculator} from '../../../types/splitio'; +import { FallbackTreatmentConfiguration, FallbackTreatment } 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): FallbackTreatment & { label?: string }; +} export class FallbackTreatmentsCalculator implements IFallbackTreatmentsCalculator { private readonly labelPrefix = 'fallback - '; - private readonly control = 'CONTROL'; private readonly fallbacks: FallbackTreatmentConfiguration; - constructor(fallbacks: FallbackTreatmentConfiguration) { - this.fallbacks = fallbacks; + 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 | undefined): FallbackTreatment { - const treatment = this.fallbacks.byFlag[flagName]; + resolve(flagName: string, label?: string): FallbackTreatment & { label?: string } { + const treatment = this.fallbacks.byFlag?.[flagName]; if (treatment) { return this.copyWithLabel(treatment, label); } @@ -20,14 +32,14 @@ export class FallbackTreatmentsCalculator implements IFallbackTreatmentsCalculat } return { - treatment: this.control, + treatment: CONTROL, config: null, - label: this.resolveLabel(label), + label, }; } - private copyWithLabel(fallback: FallbackTreatment, label: string | undefined): FallbackTreatment { - if (typeof fallback === 'string') { + private copyWithLabel(fallback: string | FallbackTreatment, label?: string): FallbackTreatment & { label: string } { + if (isString(fallback)) { return { treatment: fallback, config: null, @@ -42,8 +54,8 @@ export class FallbackTreatmentsCalculator implements IFallbackTreatmentsCalculat }; } - private resolveLabel(label?: string | undefined): string | undefined { - return label ? `${this.labelPrefix}${label}` : undefined; + private resolveLabel(label?: string): string { + return label ? `${this.labelPrefix}${label}` : ''; } } diff --git a/src/sdkClient/__tests__/clientInputValidation.spec.ts b/src/sdkClient/__tests__/clientInputValidation.spec.ts index f70845f7..4a80541d 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 = { isReady: () => 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 0e526f72..2fbb7dd7 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,9 +143,17 @@ 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; log.info(IMPRESSION, [featureFlagName, matchingKey, treatment, label]); + if (treatment === CONTROL) { + const fallbackTreatment = fallbackTreatmentsCalculator.resolve(featureFlagName, label); + treatment = fallbackTreatment.treatment; + label = fallbackTreatment.label ? fallbackTreatment.label : label; + config = fallbackTreatment.config; + } + if (validateSplitExistence(log, readinessManager, featureFlagName, label, invokingMethodName)) { log.info(IMPRESSION_QUEUEING); queue.push({ diff --git a/src/sdkClient/clientInputValidation.ts b/src/sdkClient/clientInputValidation.ts index 40765d41..bd371a7c 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/types/splitio.d.ts b/types/splitio.d.ts index f1ac014f..cfe069ad 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -1233,25 +1233,18 @@ declare namespace SplitIO { */ type ConsentStatus = 'GRANTED' | 'DECLINED' | 'UNKNOWN'; /** - * Fallback treatment can be either a string (treatment) or an object with treatment, config and label. + * Fallback treatment can be either a string (Treatment) or an object with treatment and config (TreatmentWithConfig). */ - type FallbackTreatment = string | { - treatment: string; - config?: string | null; - label?: string | null; - }; + type FallbackTreatment = TreatmentWithConfig; /** * Fallback treatments to be used when the SDK is not ready or the flag is not found. */ type FallbackTreatmentConfiguration = { - global?: FallbackTreatment, - byFlag: { - [key: string]: FallbackTreatment + global?: string | FallbackTreatment, + byFlag?: { + [key: string]: string | FallbackTreatment } } - type IFallbackTreatmentsCalculator = { - resolve(flagName: string, label?: string | undefined): FallbackTreatment; - } /** * Logger. Its interface details are not part of the public API. It shouldn't be used directly. */ From e49de68dda535708d7c9775514f998b3560ce2f7 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Wed, 22 Oct 2025 16:44:46 -0300 Subject: [PATCH 12/35] Revert "Add method to retrieve client readiness status synchronously" This reverts commit 1eeff81e5153f976290980c5e7bbcc7fe6146298. --- .../__tests__/sdkReadinessManager.spec.ts | 4 +- src/readiness/sdkReadinessManager.ts | 2 +- src/readiness/types.ts | 3 +- src/types.ts | 15 ++++++ types/splitio.d.ts | 52 ------------------- 5 files changed, 20 insertions(+), 56 deletions(-) diff --git a/src/readiness/__tests__/sdkReadinessManager.spec.ts b/src/readiness/__tests__/sdkReadinessManager.spec.ts index 35ee9d7a..9044fc72 100644 --- a/src/readiness/__tests__/sdkReadinessManager.spec.ts +++ b/src/readiness/__tests__/sdkReadinessManager.spec.ts @@ -51,8 +51,8 @@ describe('SDK Readiness Manager - Event emitter', () => { }); expect(typeof sdkStatus.ready).toBe('function'); // The sdkStatus exposes a .ready() function. - expect(typeof sdkStatus.getStatus).toBe('function'); // The sdkStatus exposes a .getStatus() function. - expect(sdkStatus.getStatus()).toEqual({ + expect(typeof sdkStatus.__getStatus).toBe('function'); // The sdkStatus exposes a .__getStatus() function. + expect(sdkStatus.__getStatus()).toEqual({ isReady: false, isReadyFromCache: false, isTimedout: false, hasTimedout: false, isDestroyed: false, isOperational: false, lastUpdate: 0 }); diff --git a/src/readiness/sdkReadinessManager.ts b/src/readiness/sdkReadinessManager.ts index 03afd873..62f51571 100644 --- a/src/readiness/sdkReadinessManager.ts +++ b/src/readiness/sdkReadinessManager.ts @@ -132,7 +132,7 @@ export function sdkReadinessManagerFactory( }); }, - getStatus() { + __getStatus() { return { isReady: readinessManager.isReady(), isReadyFromCache: readinessManager.isReadyFromCache(), diff --git a/src/readiness/types.ts b/src/readiness/types.ts index 2de99b43..df3c2603 100644 --- a/src/readiness/types.ts +++ b/src/readiness/types.ts @@ -1,3 +1,4 @@ +import { IStatusInterface } from '../types'; import SplitIO from '../../types/splitio'; /** Splits data emitter */ @@ -71,7 +72,7 @@ export interface IReadinessManager { export interface ISdkReadinessManager { readinessManager: IReadinessManager - sdkStatus: SplitIO.IStatusInterface + sdkStatus: IStatusInterface /** * Increment internalReadyCbCount, an offset value of SDK_READY listeners that are added/removed internally diff --git a/src/types.ts b/src/types.ts index 5f6c7e39..ad3fa04c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,6 +14,21 @@ export interface ISettings extends SplitIO.ISettings { readonly initialRolloutPlan?: RolloutPlan; } +/** + * SplitIO.IStatusInterface interface extended with private properties for internal use + */ +export interface IStatusInterface extends SplitIO.IStatusInterface { + // Expose status for internal purposes only. Not considered part of the public API, and might be updated eventually. + __getStatus(): { + isReady: boolean; + isReadyFromCache: boolean; + isTimedout: boolean; + hasTimedout: boolean; + isDestroyed: boolean; + isOperational: boolean; + lastUpdate: number; + }; +} /** * SplitIO.IBasicClient interface extended with private properties for internal use */ diff --git a/types/splitio.d.ts b/types/splitio.d.ts index b3884694..0a8dfda2 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -691,52 +691,6 @@ declare namespace SplitIO { [status in ConsentStatus]: ConsentStatus; }; } - /** - * Readiness Status interface. It represents the readiness state of an SDK client. - */ - interface ReadinessStatus { - - /** - * `isReady` indicates if the client has triggered an `SDK_READY` event and - * thus is ready to evaluate with cached data synchronized with the backend. - */ - isReady: boolean; - - /** - * `isReadyFromCache` indicates if the client has triggered an `SDK_READY_FROM_CACHE` event and - * thus is ready to evaluate with cached data, although the data in cache might be stale, not synchronized with the backend. - */ - isReadyFromCache: boolean; - - /** - * `isTimedout` indicates if the client has triggered an `SDK_READY_TIMED_OUT` event and is not ready to evaluate. - * In other words, `isTimedout` is equivalent to `hasTimedout && !isReady`. - */ - isTimedout: boolean; - - /** - * `hasTimedout` indicates if the client has ever triggered an `SDK_READY_TIMED_OUT` event. - * It's meant to keep a reference that the SDK emitted a timeout at some point, not the current state. - */ - hasTimedout: boolean; - - /** - * `isDestroyed` indicates if the client has been destroyed, i.e., `destroy` method has been called. - */ - isDestroyed: boolean; - - /** - * `isOperational` indicates if the client can evaluate feature flags. - * In this state, `getTreatment` calls will not return `CONTROL` due to the SDK being unready or destroyed. - * It's equivalent to `isReadyFromCache && !isDestroyed`. - */ - isOperational: boolean; - - /** - * `lastUpdate` indicates the timestamp of the most recent status event. - */ - lastUpdate: number; - } /** * Common API for entities that expose status handlers. */ @@ -745,12 +699,6 @@ declare namespace SplitIO { * Constant object containing the SDK events for you to use. */ Event: EventConsts; - /** - * Gets the readiness status. - * - * @returns The current readiness status. - */ - getStatus(): ReadinessStatus; /** * Returns a promise that resolves once the SDK has finished synchronizing with the backend (`SDK_READY` event emitted) or rejected if the SDK has timedout (`SDK_READY_TIMED_OUT` event emitted). * As it's meant to provide similar flexibility to the event approach, given that the SDK might be eventually ready after a timeout event, the `ready` method will return a resolved promise once the SDK is ready. From 818235bcfca9817a4f73e35ca6b20d8486741c07 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Wed, 22 Oct 2025 16:48:09 -0300 Subject: [PATCH 13/35] Revert "Revert "Add method to retrieve client readiness status synchronously"" This reverts commit e49de68dda535708d7c9775514f998b3560ce2f7. --- .../__tests__/sdkReadinessManager.spec.ts | 4 +- src/readiness/sdkReadinessManager.ts | 2 +- src/readiness/types.ts | 3 +- src/types.ts | 15 ------ types/splitio.d.ts | 52 +++++++++++++++++++ 5 files changed, 56 insertions(+), 20 deletions(-) diff --git a/src/readiness/__tests__/sdkReadinessManager.spec.ts b/src/readiness/__tests__/sdkReadinessManager.spec.ts index 9044fc72..35ee9d7a 100644 --- a/src/readiness/__tests__/sdkReadinessManager.spec.ts +++ b/src/readiness/__tests__/sdkReadinessManager.spec.ts @@ -51,8 +51,8 @@ describe('SDK Readiness Manager - Event emitter', () => { }); expect(typeof sdkStatus.ready).toBe('function'); // The sdkStatus exposes a .ready() function. - expect(typeof sdkStatus.__getStatus).toBe('function'); // The sdkStatus exposes a .__getStatus() function. - expect(sdkStatus.__getStatus()).toEqual({ + expect(typeof sdkStatus.getStatus).toBe('function'); // The sdkStatus exposes a .getStatus() function. + expect(sdkStatus.getStatus()).toEqual({ isReady: false, isReadyFromCache: false, isTimedout: false, hasTimedout: false, isDestroyed: false, isOperational: false, lastUpdate: 0 }); diff --git a/src/readiness/sdkReadinessManager.ts b/src/readiness/sdkReadinessManager.ts index 62f51571..03afd873 100644 --- a/src/readiness/sdkReadinessManager.ts +++ b/src/readiness/sdkReadinessManager.ts @@ -132,7 +132,7 @@ export function sdkReadinessManagerFactory( }); }, - __getStatus() { + getStatus() { return { isReady: readinessManager.isReady(), isReadyFromCache: readinessManager.isReadyFromCache(), diff --git a/src/readiness/types.ts b/src/readiness/types.ts index df3c2603..2de99b43 100644 --- a/src/readiness/types.ts +++ b/src/readiness/types.ts @@ -1,4 +1,3 @@ -import { IStatusInterface } from '../types'; import SplitIO from '../../types/splitio'; /** Splits data emitter */ @@ -72,7 +71,7 @@ export interface IReadinessManager { export interface ISdkReadinessManager { readinessManager: IReadinessManager - sdkStatus: IStatusInterface + sdkStatus: SplitIO.IStatusInterface /** * Increment internalReadyCbCount, an offset value of SDK_READY listeners that are added/removed internally diff --git a/src/types.ts b/src/types.ts index ad3fa04c..5f6c7e39 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,21 +14,6 @@ export interface ISettings extends SplitIO.ISettings { readonly initialRolloutPlan?: RolloutPlan; } -/** - * SplitIO.IStatusInterface interface extended with private properties for internal use - */ -export interface IStatusInterface extends SplitIO.IStatusInterface { - // Expose status for internal purposes only. Not considered part of the public API, and might be updated eventually. - __getStatus(): { - isReady: boolean; - isReadyFromCache: boolean; - isTimedout: boolean; - hasTimedout: boolean; - isDestroyed: boolean; - isOperational: boolean; - lastUpdate: number; - }; -} /** * SplitIO.IBasicClient interface extended with private properties for internal use */ diff --git a/types/splitio.d.ts b/types/splitio.d.ts index 0a8dfda2..b3884694 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -691,6 +691,52 @@ declare namespace SplitIO { [status in ConsentStatus]: ConsentStatus; }; } + /** + * Readiness Status interface. It represents the readiness state of an SDK client. + */ + interface ReadinessStatus { + + /** + * `isReady` indicates if the client has triggered an `SDK_READY` event and + * thus is ready to evaluate with cached data synchronized with the backend. + */ + isReady: boolean; + + /** + * `isReadyFromCache` indicates if the client has triggered an `SDK_READY_FROM_CACHE` event and + * thus is ready to evaluate with cached data, although the data in cache might be stale, not synchronized with the backend. + */ + isReadyFromCache: boolean; + + /** + * `isTimedout` indicates if the client has triggered an `SDK_READY_TIMED_OUT` event and is not ready to evaluate. + * In other words, `isTimedout` is equivalent to `hasTimedout && !isReady`. + */ + isTimedout: boolean; + + /** + * `hasTimedout` indicates if the client has ever triggered an `SDK_READY_TIMED_OUT` event. + * It's meant to keep a reference that the SDK emitted a timeout at some point, not the current state. + */ + hasTimedout: boolean; + + /** + * `isDestroyed` indicates if the client has been destroyed, i.e., `destroy` method has been called. + */ + isDestroyed: boolean; + + /** + * `isOperational` indicates if the client can evaluate feature flags. + * In this state, `getTreatment` calls will not return `CONTROL` due to the SDK being unready or destroyed. + * It's equivalent to `isReadyFromCache && !isDestroyed`. + */ + isOperational: boolean; + + /** + * `lastUpdate` indicates the timestamp of the most recent status event. + */ + lastUpdate: number; + } /** * Common API for entities that expose status handlers. */ @@ -699,6 +745,12 @@ declare namespace SplitIO { * Constant object containing the SDK events for you to use. */ Event: EventConsts; + /** + * Gets the readiness status. + * + * @returns The current readiness status. + */ + getStatus(): ReadinessStatus; /** * Returns a promise that resolves once the SDK has finished synchronizing with the backend (`SDK_READY` event emitted) or rejected if the SDK has timedout (`SDK_READY_TIMED_OUT` event emitted). * As it's meant to provide similar flexibility to the event approach, given that the SDK might be eventually ready after a timeout event, the `ready` method will return a resolved promise once the SDK is ready. From 36ca35e1d0b1aceb2407adfdc5e7cfb0e4a992d2 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Wed, 22 Oct 2025 17:17:05 -0300 Subject: [PATCH 14/35] Polishing --- CHANGES.txt | 1 + types/splitio.d.ts | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index fdab3d6d..8d931493 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,4 +1,5 @@ 2.8.0 (October XX, 2025) + - Added `client.whenReady()` and `client.whenReadyFromCache()` methods to replace the deprecated `client.ready()` method, which had 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. 2.7.1 (October 8, 2025) diff --git a/types/splitio.d.ts b/types/splitio.d.ts index 0a8dfda2..49f70c62 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -700,7 +700,7 @@ declare namespace SplitIO { */ Event: EventConsts; /** - * Returns a promise that resolves once the SDK has finished synchronizing with the backend (`SDK_READY` event emitted) or rejected if the SDK has timedout (`SDK_READY_TIMED_OUT` event emitted). + * Returns a promise that resolves when the SDK has finished initial synchronization with the backend (`SDK_READY` event emitted), or rejected if the SDK has timedout (`SDK_READY_TIMED_OUT` event emitted). * As it's meant to provide similar flexibility to the event approach, given that the SDK might be eventually ready after a timeout event, the `ready` method will return a resolved promise once the SDK is ready. * * Caveats: the method was designed to avoid an unhandled Promise rejection if the rejection case is not handled, so that `onRejected` handler is optional when using promises. @@ -719,15 +719,17 @@ declare namespace SplitIO { */ ready(): Promise; /** - * Returns a promise that resolves once the SDK is ready for evaluations using cached data synchronized with the backend (`SDK_READY` event emitted) or rejected if the SDK has timedout (`SDK_READY_TIMED_OUT` event emitted). + * Returns a promise that resolves when the SDK has finished initial synchronization with the backend (`SDK_READY` event emitted), or rejected if the SDK has timedout (`SDK_READY_TIMED_OUT` event emitted). * As it's meant to provide similar flexibility than event listeners, given that the SDK might be ready after a timeout event, the `whenReady` method will return a resolved promise once the SDK is ready. + * You must handle the promise rejection to avoid an unhandled promise rejection error, or set the `startup.readyTimeout` configuration option to 0 to avoid the timeout and thus the rejection. * * @returns A promise that resolves once the SDK_READY event is emitted or rejects if the SDK has timedout. */ whenReady(): Promise; /** - * Returns a promise that resolves once the SDK is ready for evaluations using cached data which might not yet be synchronized with the backend (`SDK_READY_FROM_CACHE` event emitted) or rejected if the SDK has timedout (`SDK_READY_TIMED_OUT` event emitted). + * Returns a promise that resolves when the SDK is ready for evaluations using cached data, which might not yet be synchronized with the backend (`SDK_READY_FROM_CACHE` event emitted), or rejected if the SDK has timedout (`SDK_READY_TIMED_OUT` event emitted). * As it's meant to provide similar flexibility than event listeners, given that the SDK might be ready from cache after a timeout event, the `whenReadyFromCache` method will return a resolved promise once the SDK is ready from cache. + * You must handle the promise rejection to avoid an unhandled promise rejection error, or set the `startup.readyTimeout` configuration option to 0 to avoid the timeout and thus the rejection. * * @returns A promise that resolves once the SDK_READY_FROM_CACHE event is emitted or rejects if the SDK has timedout. The promise resolves with a boolean value that * indicates whether the SDK_READY_FROM_CACHE event was emitted together with the SDK_READY event (i.e., the SDK is ready and synchronized with the backend) or not. From b8719b299df104bc2759861f22cb78b91958aec1 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Thu, 23 Oct 2025 11:20:33 -0300 Subject: [PATCH 15/35] Polishing --- CHANGES.txt | 2 +- .../__tests__/sdkReadinessManager.spec.ts | 72 +++++++++++++------ src/readiness/sdkReadinessManager.ts | 4 +- 3 files changed, 55 insertions(+), 23 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index 8d931493..9163d52f 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,5 +1,5 @@ 2.8.0 (October XX, 2025) - - Added `client.whenReady()` and `client.whenReadyFromCache()` methods to replace the deprecated `client.ready()` method, which had an issue causing the returned promise to hang when using async/await syntax if it was rejected. + - 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. 2.7.1 (October 8, 2025) diff --git a/src/readiness/__tests__/sdkReadinessManager.spec.ts b/src/readiness/__tests__/sdkReadinessManager.spec.ts index 9044fc72..d4de5124 100644 --- a/src/readiness/__tests__/sdkReadinessManager.spec.ts +++ b/src/readiness/__tests__/sdkReadinessManager.spec.ts @@ -1,11 +1,12 @@ // @ts-nocheck import { loggerMock } from '../../logger/__tests__/sdkLogger.mock'; import SplitIO from '../../../types/splitio'; -import { SDK_READY, SDK_READY_FROM_CACHE, SDK_READY_TIMED_OUT, SDK_UPDATE } from '../constants'; +import { SDK_READY, SDK_READY_FROM_CACHE, SDK_READY_TIMED_OUT, SDK_UPDATE, SDK_SPLITS_ARRIVED, SDK_SEGMENTS_ARRIVED } from '../constants'; import { sdkReadinessManagerFactory } from '../sdkReadinessManager'; import { IReadinessManager } from '../types'; import { ERROR_CLIENT_LISTENER, CLIENT_READY_FROM_CACHE, CLIENT_READY, CLIENT_NO_LISTENER } from '../../logger/constants'; import { fullSettings } from '../../utils/settingsValidation/__tests__/settings.mocks'; +import { EventEmitter } from '../../utils/MinEvents'; const EventEmitterMock = jest.fn(() => ({ on: jest.fn(), @@ -24,6 +25,7 @@ function emitReadyEvent(readinessManager: IReadinessManager) { readinessManager.segments.once.mock.calls[0][1](); readinessManager.segments.on.mock.calls[0][1](); readinessManager.gate.once.mock.calls[0][1](); + if (readinessManager.gate.once.mock.calls[3]) readinessManager.gate.once.mock.calls[3][1](); // whenReady promise } const timeoutErrorMessage = 'Split SDK emitted SDK_READY_TIMED_OUT event.'; @@ -32,6 +34,7 @@ const timeoutErrorMessage = 'Split SDK emitted SDK_READY_TIMED_OUT event.'; function emitTimeoutEvent(readinessManager: IReadinessManager) { readinessManager.gate.once.mock.calls[1][1](timeoutErrorMessage); readinessManager.hasTimedout = () => true; + if (readinessManager.gate.once.mock.calls[4]) readinessManager.gate.once.mock.calls[4][1](timeoutErrorMessage); // whenReady promise } describe('SDK Readiness Manager - Event emitter', () => { @@ -50,7 +53,8 @@ describe('SDK Readiness Manager - Event emitter', () => { expect(sdkStatus[propName]).toBeTruthy(); // The sdkStatus exposes all minimal EventEmitter functionality. }); - expect(typeof sdkStatus.ready).toBe('function'); // The sdkStatus exposes a .ready() function. + expect(typeof sdkStatus.whenReady).toBe('function'); // The sdkStatus exposes a .whenReady() function. + expect(typeof sdkStatus.whenReadyFromCache).toBe('function'); // The sdkStatus exposes a .whenReadyFromCache() function. expect(typeof sdkStatus.__getStatus).toBe('function'); // The sdkStatus exposes a .__getStatus() function. expect(sdkStatus.__getStatus()).toEqual({ isReady: false, isReadyFromCache: false, isTimedout: false, hasTimedout: false, isDestroyed: false, isOperational: false, lastUpdate: 0 @@ -67,9 +71,9 @@ describe('SDK Readiness Manager - Event emitter', () => { const sdkReadyResolvePromiseCall = gateMock.once.mock.calls[0]; const sdkReadyRejectPromiseCall = gateMock.once.mock.calls[1]; const sdkReadyFromCacheListenersCheckCall = gateMock.once.mock.calls[2]; - expect(sdkReadyResolvePromiseCall[0]).toBe(SDK_READY); // A one time only subscription is on the SDK_READY event, for resolving the full blown ready promise and to check for callbacks warning. - expect(sdkReadyRejectPromiseCall[0]).toBe(SDK_READY_TIMED_OUT); // A one time only subscription is also on the SDK_READY_TIMED_OUT event, for rejecting the full blown ready promise. - expect(sdkReadyFromCacheListenersCheckCall[0]).toBe(SDK_READY_FROM_CACHE); // A one time only subscription is on the SDK_READY_FROM_CACHE event, to log the event and update internal state. + expect(sdkReadyResolvePromiseCall[0]).toBe(SDK_READY); // A one time only subscription is on the SDK_READY event + expect(sdkReadyRejectPromiseCall[0]).toBe(SDK_READY_TIMED_OUT); // A one time only subscription is also on the SDK_READY_TIMED_OUT event + expect(sdkReadyFromCacheListenersCheckCall[0]).toBe(SDK_READY_FROM_CACHE); // A one time only subscription is on the SDK_READY_FROM_CACHE event expect(gateMock.on).toBeCalledTimes(2); // It should also add two persistent listeners @@ -98,7 +102,7 @@ describe('SDK Readiness Manager - Event emitter', () => { emitReadyEvent(sdkReadinessManager.readinessManager); - expect(loggerMock.warn).toBeCalledTimes(1); // If the SDK_READY event fires and we have no callbacks for it (neither event nor ready promise) we get a warning. + expect(loggerMock.warn).toBeCalledTimes(1); // If the SDK_READY event fires and we have no callbacks for it (neither event nor whenReady promise) we get a warning. expect(loggerMock.warn).toBeCalledWith(CLIENT_NO_LISTENER); // Telling us there were no listeners and evaluations before this point may have been incorrect. expect(loggerMock.info).toBeCalledTimes(1); // If the SDK_READY event fires, we get a info message. @@ -199,15 +203,15 @@ describe('SDK Readiness Manager - Event emitter', () => { }); }); -describe('SDK Readiness Manager - Ready promise', () => { +describe('SDK Readiness Manager - whenReady promise', () => { - test('.ready() promise behavior for clients', async () => { + test('.whenReady() promise behavior for clients', async () => { const sdkReadinessManager = sdkReadinessManagerFactory(EventEmitterMock, fullSettings); - const ready = sdkReadinessManager.sdkStatus.ready(); + const ready = sdkReadinessManager.sdkStatus.whenReady(); expect(ready instanceof Promise).toBe(true); // It should return a promise. - // make the SDK "ready" + // make the SDK ready emitReadyEvent(sdkReadinessManager.readinessManager); let testPassedCount = 0; @@ -219,8 +223,8 @@ describe('SDK Readiness Manager - Ready promise', () => { () => { throw new Error('It should be resolved on ready event, not rejected.'); } ); - // any subsequent call to .ready() must be a resolved promise - await ready.then( + // any subsequent call to .whenReady() must be a resolved promise + await sdkReadinessManager.sdkStatus.whenReady().then( () => { expect('A subsequent call should be a resolved promise.'); testPassedCount++; @@ -233,9 +237,9 @@ describe('SDK Readiness Manager - Ready promise', () => { const sdkReadinessManagerForTimedout = sdkReadinessManagerFactory(EventEmitterMock, fullSettings); - const readyForTimeout = sdkReadinessManagerForTimedout.sdkStatus.ready(); + const readyForTimeout = sdkReadinessManagerForTimedout.sdkStatus.whenReady(); - emitTimeoutEvent(sdkReadinessManagerForTimedout.readinessManager); // make the SDK "timed out" + emitTimeoutEvent(sdkReadinessManagerForTimedout.readinessManager); // make the SDK timeout await readyForTimeout.then( () => { throw new Error('It should be a promise that was rejected on SDK_READY_TIMED_OUT, not resolved.'); }, @@ -245,8 +249,8 @@ describe('SDK Readiness Manager - Ready promise', () => { } ); - // any subsequent call to .ready() must be a rejected promise - await readyForTimeout.then( + // any subsequent call to .whenReady() must be a rejected promise until the SDK is ready + await sdkReadinessManagerForTimedout.sdkStatus.whenReady().then( () => { throw new Error('It should be a promise that was rejected on SDK_READY_TIMED_OUT, not resolved.'); }, () => { expect('A subsequent call should be a rejected promise.'); @@ -254,11 +258,11 @@ describe('SDK Readiness Manager - Ready promise', () => { } ); - // make the SDK "ready" + // make the SDK ready emitReadyEvent(sdkReadinessManagerForTimedout.readinessManager); - // once SDK_READY, `.ready()` returns a resolved promise - await ready.then( + // once SDK_READY, `.whenReady()` returns a resolved promise + await sdkReadinessManagerForTimedout.sdkStatus.whenReady().then( () => { expect('It should be a resolved promise when the SDK is ready, even after an SDK timeout.'); loggerMock.mockClear(); @@ -269,7 +273,35 @@ describe('SDK Readiness Manager - Ready promise', () => { ); }); - test('Full blown ready promise count as a callback and resolves on SDK_READY', (done) => { + test('whenReady promise count as a callback and resolves on SDK_READY', (done) => { + let sdkReadinessManager = sdkReadinessManagerFactory(EventEmitter, fullSettings); + + // Emit ready event + sdkReadinessManager.readinessManager.splits.emit(SDK_SPLITS_ARRIVED); + sdkReadinessManager.readinessManager.segments.emit(SDK_SEGMENTS_ARRIVED); + + expect(loggerMock.warn).toBeCalledWith(CLIENT_NO_LISTENER); // We should get a warning if the SDK get's ready before calling the whenReady method or attaching a listener to the ready event + loggerMock.warn.mockClear(); + + sdkReadinessManager = sdkReadinessManagerFactory(EventEmitter, fullSettings); + sdkReadinessManager.sdkStatus.whenReady().then(() => { + expect('whenReady promise is resolved when the gate emits SDK_READY.'); + done(); + }, () => { + throw new Error('This should not be called as the promise is being resolved.'); + }); + + // Emit ready event + sdkReadinessManager.readinessManager.splits.emit(SDK_SPLITS_ARRIVED); + sdkReadinessManager.readinessManager.segments.emit(SDK_SEGMENTS_ARRIVED); + + expect(loggerMock.warn).not.toBeCalled(); // But if we have a listener or call the whenReady method, we get no warnings. + }); +}); + +// @TODO: remove in next major +describe('SDK Readiness Manager - Ready promise', () => { + test('ready promise count as a callback and resolves on SDK_READY', (done) => { const sdkReadinessManager = sdkReadinessManagerFactory(EventEmitterMock, fullSettings); const readyPromise = sdkReadinessManager.sdkStatus.ready(); diff --git a/src/readiness/sdkReadinessManager.ts b/src/readiness/sdkReadinessManager.ts index 62f51571..857a9e84 100644 --- a/src/readiness/sdkReadinessManager.ts +++ b/src/readiness/sdkReadinessManager.ts @@ -9,7 +9,7 @@ import { ERROR_CLIENT_LISTENER, CLIENT_READY_FROM_CACHE, CLIENT_READY, CLIENT_NO const NEW_LISTENER_EVENT = 'newListener'; const REMOVE_LISTENER_EVENT = 'removeListener'; -const TIMEOUT_ERROR = new Error('Split SDK has emitted SDK_READY_TIMED_OUT event.'); +const TIMEOUT_ERROR = new Error(SDK_READY_TIMED_OUT); /** * SdkReadinessManager factory, which provides the public status API of SDK clients and manager: ready promise, readiness event emitter and constants (SDK_READY, etc). @@ -98,7 +98,7 @@ export function sdkReadinessManagerFactory( ready() { if (readinessManager.hasTimedout()) { if (!readinessManager.isReady()) { - return promiseWrapper(Promise.reject(TIMEOUT_ERROR), defaultOnRejected); + return promiseWrapper(Promise.reject(new Error('Split SDK has emitted SDK_READY_TIMED_OUT event.')), defaultOnRejected); } else { return Promise.resolve(); } From d9c4cff5923626d923bec79604f0cb2985d66642 Mon Sep 17 00:00:00 2001 From: ZamoraEmmanuel <87494075+ZamoraEmmanuel@users.noreply.github.com> Date: Thu, 23 Oct 2025 14:09:57 -0300 Subject: [PATCH 16/35] Update src/sdkClient/clientInputValidation.ts Co-authored-by: Emiliano Sanchez --- src/sdkClient/clientInputValidation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sdkClient/clientInputValidation.ts b/src/sdkClient/clientInputValidation.ts index bd371a7c..443c31f4 100644 --- a/src/sdkClient/clientInputValidation.ts +++ b/src/sdkClient/clientInputValidation.ts @@ -60,7 +60,7 @@ export function clientInputValidationDecorator Date: Thu, 23 Oct 2025 15:46:26 -0300 Subject: [PATCH 17/35] Update logs and tests --- src/logger/messages/warn.ts | 2 +- .../__tests__/sdkReadinessManager.spec.ts | 114 +++++++++--------- src/readiness/sdkReadinessManager.ts | 2 + 3 files changed, 62 insertions(+), 56 deletions(-) diff --git a/src/logger/messages/warn.ts b/src/logger/messages/warn.ts index 8f87babd..a0bf31a9 100644 --- a/src/logger/messages/warn.ts +++ b/src/logger/messages/warn.ts @@ -15,7 +15,7 @@ export const codesWarn: [number, string][] = codesError.concat([ [c.SUBMITTERS_PUSH_RETRY, c.LOG_PREFIX_SYNC_SUBMITTERS + 'Failed to push %s, keeping data to retry on next iteration. Reason: %s.'], // client status [c.CLIENT_NOT_READY_FROM_CACHE, '%s: the SDK is not ready to evaluate. Results may be incorrect%s. Make sure to wait for SDK readiness before using this method.'], - [c.CLIENT_NO_LISTENER, 'No listeners for SDK Readiness detected. Incorrect control treatments could have been logged if you called getTreatment/s while the SDK was not yet ready.'], + [c.CLIENT_NO_LISTENER, 'No listeners for SDK_READY event detected. Incorrect control treatments could have been logged if you called getTreatment/s while the SDK was not yet synchronized with the backend.'], // input validation [c.WARN_SETTING_NULL, '%s: Property "%s" is of invalid type. Setting value to null.'], [c.WARN_TRIMMING_PROPERTIES, '%s: more than 300 properties were provided. Some of them will be trimmed when processed.'], diff --git a/src/readiness/__tests__/sdkReadinessManager.spec.ts b/src/readiness/__tests__/sdkReadinessManager.spec.ts index d4de5124..4d47d12f 100644 --- a/src/readiness/__tests__/sdkReadinessManager.spec.ts +++ b/src/readiness/__tests__/sdkReadinessManager.spec.ts @@ -1,7 +1,7 @@ // @ts-nocheck import { loggerMock } from '../../logger/__tests__/sdkLogger.mock'; import SplitIO from '../../../types/splitio'; -import { SDK_READY, SDK_READY_FROM_CACHE, SDK_READY_TIMED_OUT, SDK_UPDATE, SDK_SPLITS_ARRIVED, SDK_SEGMENTS_ARRIVED } from '../constants'; +import { SDK_READY, SDK_READY_FROM_CACHE, SDK_READY_TIMED_OUT, SDK_UPDATE, SDK_SPLITS_ARRIVED, SDK_SEGMENTS_ARRIVED, SDK_SPLITS_CACHE_LOADED } from '../constants'; import { sdkReadinessManagerFactory } from '../sdkReadinessManager'; import { IReadinessManager } from '../types'; import { ERROR_CLIENT_LISTENER, CLIENT_READY_FROM_CACHE, CLIENT_READY, CLIENT_NO_LISTENER } from '../../logger/constants'; @@ -20,6 +20,12 @@ const EventEmitterMock = jest.fn(() => ({ // Makes readinessManager emit SDK_READY & update isReady flag function emitReadyEvent(readinessManager: IReadinessManager) { + if (readinessManager.gate instanceof EventEmitter) { + readinessManager.splits.emit(SDK_SPLITS_ARRIVED); + readinessManager.segments.emit(SDK_SEGMENTS_ARRIVED); + return; + } + readinessManager.splits.once.mock.calls[0][1](); readinessManager.splits.on.mock.calls[0][1](); readinessManager.segments.once.mock.calls[0][1](); @@ -32,6 +38,11 @@ const timeoutErrorMessage = 'Split SDK emitted SDK_READY_TIMED_OUT event.'; // Makes readinessManager emit SDK_READY_TIMED_OUT & update hasTimedout flag function emitTimeoutEvent(readinessManager: IReadinessManager) { + if (readinessManager.gate instanceof EventEmitter) { + readinessManager.timeout(); + return; + } + readinessManager.gate.once.mock.calls[1][1](timeoutErrorMessage); readinessManager.hasTimedout = () => true; if (readinessManager.gate.once.mock.calls[4]) readinessManager.gate.once.mock.calls[4][1](timeoutErrorMessage); // whenReady promise @@ -39,7 +50,7 @@ function emitTimeoutEvent(readinessManager: IReadinessManager) { describe('SDK Readiness Manager - Event emitter', () => { - afterEach(() => { loggerMock.mockClear(); }); + beforeEach(() => { loggerMock.mockClear(); }); test('Providing the gate object to get the SDK status interface that manages events', () => { expect(typeof sdkReadinessManagerFactory).toBe('function'); // The module exposes a function. @@ -203,82 +214,74 @@ describe('SDK Readiness Manager - Event emitter', () => { }); }); -describe('SDK Readiness Manager - whenReady promise', () => { +describe('SDK Readiness Manager - Promises', () => { - test('.whenReady() promise behavior for clients', async () => { - const sdkReadinessManager = sdkReadinessManagerFactory(EventEmitterMock, fullSettings); + test('.whenReady() and .whenReadyFromCache() promises resolves when SDK_READY is emitted', async () => { + const sdkReadinessManager = sdkReadinessManagerFactory(EventEmitter, fullSettings); + + // make the SDK ready from cache + sdkReadinessManager.readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED); + expect(await sdkReadinessManager.sdkStatus.whenReadyFromCache()).toBe(false); + + // validate error log for SDK_READY_FROM_CACHE + expect(loggerMock.error).not.toBeCalled(); + sdkReadinessManager.readinessManager.gate.on(SDK_READY_FROM_CACHE, () => {}); + expect(loggerMock.error).toBeCalledWith(ERROR_CLIENT_LISTENER, ['SDK_READY_FROM_CACHE']); + const readyFromCache = sdkReadinessManager.sdkStatus.whenReadyFromCache(); const ready = sdkReadinessManager.sdkStatus.whenReady(); - expect(ready instanceof Promise).toBe(true); // It should return a promise. // make the SDK ready emitReadyEvent(sdkReadinessManager.readinessManager); + expect(await sdkReadinessManager.sdkStatus.whenReadyFromCache()).toBe(true); let testPassedCount = 0; - await ready.then( - () => { - expect('It should be a promise that will be resolved when the SDK is ready.'); - testPassedCount++; - }, - () => { throw new Error('It should be resolved on ready event, not rejected.'); } - ); + function incTestPassedCount() { testPassedCount++; } + function throwTestFailed() { throw new Error('It should be resolved, not rejected.'); } - // any subsequent call to .whenReady() must be a resolved promise - await sdkReadinessManager.sdkStatus.whenReady().then( - () => { - expect('A subsequent call should be a resolved promise.'); - testPassedCount++; - }, - () => { throw new Error('It should be resolved on ready event, not rejected.'); } - ); + await readyFromCache.then(incTestPassedCount, throwTestFailed); + await ready.then(incTestPassedCount, throwTestFailed); - // control assertion. stubs already reset. - expect(testPassedCount).toBe(2); + // any subsequent call to .whenReady() and .whenReadyFromCache() must be a resolved promise + await sdkReadinessManager.sdkStatus.whenReady().then(incTestPassedCount, throwTestFailed); + await sdkReadinessManager.sdkStatus.whenReadyFromCache().then(incTestPassedCount, throwTestFailed); - const sdkReadinessManagerForTimedout = sdkReadinessManagerFactory(EventEmitterMock, fullSettings); + expect(testPassedCount).toBe(4); + }); + test('.whenReady() and .whenReadyFromCache() promises reject when SDK_READY_TIMED_OUT is emitted before SDK_READY', async () => { + const sdkReadinessManagerForTimedout = sdkReadinessManagerFactory(EventEmitter, fullSettings); + + const readyFromCacheForTimeout = sdkReadinessManagerForTimedout.sdkStatus.whenReadyFromCache(); const readyForTimeout = sdkReadinessManagerForTimedout.sdkStatus.whenReady(); emitTimeoutEvent(sdkReadinessManagerForTimedout.readinessManager); // make the SDK timeout - await readyForTimeout.then( - () => { throw new Error('It should be a promise that was rejected on SDK_READY_TIMED_OUT, not resolved.'); }, - () => { - expect('It should be a promise that will be rejected when the SDK is timed out.'); - testPassedCount++; - } - ); + let testPassedCount = 0; + function incTestPassedCount() { testPassedCount++; } + function throwTestFailed() { throw new Error('It should rejected, not resolved.'); } - // any subsequent call to .whenReady() must be a rejected promise until the SDK is ready - await sdkReadinessManagerForTimedout.sdkStatus.whenReady().then( - () => { throw new Error('It should be a promise that was rejected on SDK_READY_TIMED_OUT, not resolved.'); }, - () => { - expect('A subsequent call should be a rejected promise.'); - testPassedCount++; - } - ); + await readyFromCacheForTimeout.then(throwTestFailed,incTestPassedCount); + await readyForTimeout.then(throwTestFailed,incTestPassedCount); + + // any subsequent call to .whenReady() and .whenReadyFromCache() must be a rejected promise until the SDK is ready + await sdkReadinessManagerForTimedout.sdkStatus.whenReadyFromCache().then(throwTestFailed,incTestPassedCount); + await sdkReadinessManagerForTimedout.sdkStatus.whenReady().then(throwTestFailed,incTestPassedCount); // make the SDK ready emitReadyEvent(sdkReadinessManagerForTimedout.readinessManager); // once SDK_READY, `.whenReady()` returns a resolved promise - await sdkReadinessManagerForTimedout.sdkStatus.whenReady().then( - () => { - expect('It should be a resolved promise when the SDK is ready, even after an SDK timeout.'); - loggerMock.mockClear(); - testPassedCount++; - expect(testPassedCount).toBe(5); - }, - () => { throw new Error('It should be resolved on ready event, not rejected.'); } - ); + await sdkReadinessManagerForTimedout.sdkStatus.whenReady().then(incTestPassedCount, throwTestFailed); + await sdkReadinessManagerForTimedout.sdkStatus.whenReadyFromCache().then(incTestPassedCount, throwTestFailed); + + expect(testPassedCount).toBe(6); }); - test('whenReady promise count as a callback and resolves on SDK_READY', (done) => { + test('whenReady promise counts as an SDK_READY listener', (done) => { let sdkReadinessManager = sdkReadinessManagerFactory(EventEmitter, fullSettings); - // Emit ready event - sdkReadinessManager.readinessManager.splits.emit(SDK_SPLITS_ARRIVED); - sdkReadinessManager.readinessManager.segments.emit(SDK_SEGMENTS_ARRIVED); + emitReadyEvent(sdkReadinessManager.readinessManager); expect(loggerMock.warn).toBeCalledWith(CLIENT_NO_LISTENER); // We should get a warning if the SDK get's ready before calling the whenReady method or attaching a listener to the ready event loggerMock.warn.mockClear(); @@ -291,9 +294,7 @@ describe('SDK Readiness Manager - whenReady promise', () => { throw new Error('This should not be called as the promise is being resolved.'); }); - // Emit ready event - sdkReadinessManager.readinessManager.splits.emit(SDK_SPLITS_ARRIVED); - sdkReadinessManager.readinessManager.segments.emit(SDK_SEGMENTS_ARRIVED); + emitReadyEvent(sdkReadinessManager.readinessManager); expect(loggerMock.warn).not.toBeCalled(); // But if we have a listener or call the whenReady method, we get no warnings. }); @@ -301,6 +302,9 @@ describe('SDK Readiness Manager - whenReady promise', () => { // @TODO: remove in next major describe('SDK Readiness Manager - Ready promise', () => { + + beforeEach(() => { loggerMock.mockClear(); }); + test('ready promise count as a callback and resolves on SDK_READY', (done) => { const sdkReadinessManager = sdkReadinessManagerFactory(EventEmitterMock, fullSettings); const readyPromise = sdkReadinessManager.sdkStatus.ready(); diff --git a/src/readiness/sdkReadinessManager.ts b/src/readiness/sdkReadinessManager.ts index 857a9e84..64e518b3 100644 --- a/src/readiness/sdkReadinessManager.ts +++ b/src/readiness/sdkReadinessManager.ts @@ -39,6 +39,8 @@ export function sdkReadinessManagerFactory( } else if (event === SDK_READY) { readyCbCount++; } + } else if (event === SDK_READY_FROM_CACHE && readinessManager.isReadyFromCache()) { + log.error(ERROR_CLIENT_LISTENER, ['SDK_READY_FROM_CACHE']); } }); From c60c4cef28cbcb5cde1808fd6c9c80530c5cc957 Mon Sep 17 00:00:00 2001 From: Emmanuel Zamora Date: Fri, 24 Oct 2025 13:42:42 -0300 Subject: [PATCH 18/35] review changes and add fallbacklabel to avoid impression --- .../__tests__/fallback-calculator.spec.ts | 16 --------------- .../fallbackSanitizer/index.ts | 12 +++++------ .../fallbackTreatmentsCalculator/index.ts | 20 ++++++++----------- src/sdkClient/client.ts | 3 ++- src/utils/inputValidation/splitExistence.ts | 4 ++-- src/utils/labels/index.ts | 3 +++ types/splitio.d.ts | 8 ++------ 7 files changed, 23 insertions(+), 43 deletions(-) diff --git a/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts index 4a1e9d93..6240e2a0 100644 --- a/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts +++ b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts @@ -131,20 +131,4 @@ describe('FallbackTreatmentsCalculator' , () => { label: 'label by noFallback', }); }); - - test('returns undefined label if no label provided', () => { - const config: FallbackTreatmentConfiguration = { - byFlag: { - 'featureB': { treatment: 'TREATMENT_B', config: '{ value: 1 }' }, - }, - }; - const calculator = new FallbackTreatmentsCalculator(loggerMock, config); - const result = calculator.resolve('featureB'); - - expect(result).toEqual({ - treatment: 'TREATMENT_B', - config: '{ value: 1 }', - label: '', - }); - }); }); diff --git a/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts b/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts index d7996bbb..66ec65f3 100644 --- a/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts +++ b/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts @@ -1,4 +1,4 @@ -import { FallbackTreatment, Treatment, TreatmentWithConfig } from '../../../../types/splitio'; +import { Treatment, TreatmentWithConfig } from '../../../../types/splitio'; import { ILogger } from '../../../logger/types'; import { isObject, isString } from '../../../utils/lang'; import { FallbackDiscardReason } from '../constants'; @@ -12,7 +12,7 @@ export class FallbacksSanitizer { return name.length <= 100 && !name.includes(' '); } - private static isValidTreatment(t?: Treatment | FallbackTreatment): boolean { + private static isValidTreatment(t?: Treatment | TreatmentWithConfig): boolean { const treatment = isObject(t) ? (t as TreatmentWithConfig).treatment : t; if (!isString(treatment) || treatment.length > 100) { @@ -21,7 +21,7 @@ export class FallbacksSanitizer { return FallbacksSanitizer.pattern.test(treatment); } - static sanitizeGlobal(logger: ILogger, treatment?: string | FallbackTreatment): string | FallbackTreatment | undefined { + static sanitizeGlobal(logger: ILogger, treatment?: Treatment | TreatmentWithConfig): Treatment | TreatmentWithConfig | undefined { if (!this.isValidTreatment(treatment)) { logger.error( `Fallback treatments - Discarded fallback: ${FallbackDiscardReason.Treatment}` @@ -33,9 +33,9 @@ export class FallbacksSanitizer { static sanitizeByFlag( logger: ILogger, - byFlagFallbacks: Record - ): Record { - const sanitizedByFlag: Record = {}; + byFlagFallbacks: Record + ): Record { + const sanitizedByFlag: Record = {}; const entries = Object.keys(byFlagFallbacks); entries.forEach((flag) => { diff --git a/src/evaluator/fallbackTreatmentsCalculator/index.ts b/src/evaluator/fallbackTreatmentsCalculator/index.ts index 521b9419..7921f72c 100644 --- a/src/evaluator/fallbackTreatmentsCalculator/index.ts +++ b/src/evaluator/fallbackTreatmentsCalculator/index.ts @@ -1,15 +1,16 @@ -import { FallbackTreatmentConfiguration, FallbackTreatment } from '../../../types/splitio'; +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): FallbackTreatment & { label?: string }; + resolve(flagName: string, label: string): TreatmentWithConfig & { label: string }; } +export const FALLBACK_PREFIX = 'fallback - '; + export class FallbackTreatmentsCalculator implements IFallbackTreatmentsCalculator { - private readonly labelPrefix = 'fallback - '; private readonly fallbacks: FallbackTreatmentConfiguration; constructor(logger: ILogger, fallbacks?: FallbackTreatmentConfiguration) { @@ -21,7 +22,7 @@ export class FallbackTreatmentsCalculator implements IFallbackTreatmentsCalculat }; } - resolve(flagName: string, label?: string): FallbackTreatment & { label?: string } { + resolve(flagName: string, label: string): TreatmentWithConfig & { label: string } { const treatment = this.fallbacks.byFlag?.[flagName]; if (treatment) { return this.copyWithLabel(treatment, label); @@ -38,24 +39,19 @@ export class FallbackTreatmentsCalculator implements IFallbackTreatmentsCalculat }; } - private copyWithLabel(fallback: string | FallbackTreatment, label?: string): FallbackTreatment & { label: string } { + private copyWithLabel(fallback: Treatment | TreatmentWithConfig, label: string): TreatmentWithConfig & { label: string } { if (isString(fallback)) { return { treatment: fallback, config: null, - label: this.resolveLabel(label), + label: `${FALLBACK_PREFIX}${label}`, }; } return { treatment: fallback.treatment, config: fallback.config, - label: this.resolveLabel(label), + label: `${FALLBACK_PREFIX}${label}`, }; } - - private resolveLabel(label?: string): string { - return label ? `${this.labelPrefix}${label}` : ''; - } - } diff --git a/src/sdkClient/client.ts b/src/sdkClient/client.ts index 2fbb7dd7..255ed918 100644 --- a/src/sdkClient/client.ts +++ b/src/sdkClient/client.ts @@ -145,7 +145,6 @@ export function clientFactory(params: ISdkFactoryContext): SplitIO.IClient | Spl const { changeNumber, impressionsDisabled } = evaluation; let { treatment, label, config = null } = evaluation; - log.info(IMPRESSION, [featureFlagName, matchingKey, treatment, label]); if (treatment === CONTROL) { const fallbackTreatment = fallbackTreatmentsCalculator.resolve(featureFlagName, label); @@ -154,6 +153,8 @@ export function clientFactory(params: ISdkFactoryContext): SplitIO.IClient | Spl config = fallbackTreatment.config; } + log.info(IMPRESSION, [featureFlagName, matchingKey, treatment, label]); + if (validateSplitExistence(log, readinessManager, featureFlagName, label, invokingMethodName)) { log.info(IMPRESSION_QUEUEING); queue.push({ diff --git a/src/utils/inputValidation/splitExistence.ts b/src/utils/inputValidation/splitExistence.ts index 2f3105f9..f98d81b7 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 we validate this, otherwise it may just be that the SDK is not ready yet. - if (labelOrSplitObj === SPLIT_NOT_FOUND || labelOrSplitObj == null) { + if (labelOrSplitObj === SPLIT_NOT_FOUND || labelOrSplitObj == null || labelOrSplitObj === FALLBACK_SPLIT_NOT_FOUND) { 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 cfe069ad..23bd3226 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -1232,17 +1232,13 @@ declare namespace SplitIO { * User consent status. */ type ConsentStatus = 'GRANTED' | 'DECLINED' | 'UNKNOWN'; - /** - * Fallback treatment can be either a string (Treatment) or an object with treatment and config (TreatmentWithConfig). - */ - type FallbackTreatment = TreatmentWithConfig; /** * Fallback treatments to be used when the SDK is not ready or the flag is not found. */ type FallbackTreatmentConfiguration = { - global?: string | FallbackTreatment, + global?: Treatment | TreatmentWithConfig, byFlag?: { - [key: string]: string | FallbackTreatment + [featureFlagName: string]: Treatment | TreatmentWithConfig } } /** From eeb8073521e830563a447830259977707933164d Mon Sep 17 00:00:00 2001 From: Emmanuel Zamora Date: Fri, 24 Oct 2025 13:58:03 -0300 Subject: [PATCH 19/35] Prepare release v2.8.0 --- CHANGES.txt | 3 +++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index 711d3ca3..f302f601 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,6 @@ +2.8.0 (October 24, 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. + 2.7.0 (October 7, 2025) - Added support for custom loggers: added `logger` configuration option and `LoggerAPI.setLogger` method to allow the SDK to use a custom logger. diff --git a/package-lock.json b/package-lock.json index 7665ca86..a6093d8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@splitsoftware/splitio-commons", - "version": "2.7.0", + "version": "2.8.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@splitsoftware/splitio-commons", - "version": "2.7.0", + "version": "2.8.0", "license": "Apache-2.0", "dependencies": { "@types/ioredis": "^4.28.0", diff --git a/package.json b/package.json index c14387e2..ab0a1d2f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@splitsoftware/splitio-commons", - "version": "2.7.0", + "version": "2.8.0", "description": "Split JavaScript SDK common components", "main": "cjs/index.js", "module": "esm/index.js", From 6cf13c9ef8d5cb3382bb2ffef09818d6d2f6d5ce Mon Sep 17 00:00:00 2001 From: Emmanuel Zamora Date: Fri, 24 Oct 2025 16:00:27 -0300 Subject: [PATCH 20/35] remove unnecessary validation --- src/sdkClient/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sdkClient/client.ts b/src/sdkClient/client.ts index 255ed918..e8451c34 100644 --- a/src/sdkClient/client.ts +++ b/src/sdkClient/client.ts @@ -149,7 +149,7 @@ export function clientFactory(params: ISdkFactoryContext): SplitIO.IClient | Spl if (treatment === CONTROL) { const fallbackTreatment = fallbackTreatmentsCalculator.resolve(featureFlagName, label); treatment = fallbackTreatment.treatment; - label = fallbackTreatment.label ? fallbackTreatment.label : label; + label = fallbackTreatment.label; config = fallbackTreatment.config; } From ad8d66ac38d9d3caed6a5866e15f1660090a0038 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Mon, 27 Oct 2025 18:59:50 -0300 Subject: [PATCH 21/35] rc --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index a6093d8d..7284cf89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@splitsoftware/splitio-commons", - "version": "2.8.0", + "version": "2.7.9-rc.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@splitsoftware/splitio-commons", - "version": "2.8.0", + "version": "2.7.9-rc.1", "license": "Apache-2.0", "dependencies": { "@types/ioredis": "^4.28.0", diff --git a/package.json b/package.json index ab0a1d2f..ed693bcd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@splitsoftware/splitio-commons", - "version": "2.8.0", + "version": "2.7.9-rc.1", "description": "Split JavaScript SDK common components", "main": "cjs/index.js", "module": "esm/index.js", From 02447ea8f81f4fd7602f41b9c706f20db05dcada Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Tue, 28 Oct 2025 12:50:24 -0300 Subject: [PATCH 22/35] Keep __getStatus to avoid breaking change --- CHANGES.txt | 3 ++- src/readiness/sdkReadinessManager.ts | 25 ++++++++++++++----------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index 9163d52f..8f6e73d9 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,4 +1,5 @@ -2.8.0 (October XX, 2025) +2.8.0 (October 28, 2025) + - 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/src/readiness/sdkReadinessManager.ts b/src/readiness/sdkReadinessManager.ts index ee8be19c..d3b841de 100644 --- a/src/readiness/sdkReadinessManager.ts +++ b/src/readiness/sdkReadinessManager.ts @@ -72,6 +72,17 @@ export function sdkReadinessManagerFactory( return promise; } + function getStatus() { + return { + isReady: readinessManager.isReady(), + isReadyFromCache: readinessManager.isReadyFromCache(), + isTimedout: readinessManager.isTimedout(), + hasTimedout: readinessManager.hasTimedout(), + isDestroyed: readinessManager.isDestroyed(), + isOperational: readinessManager.isOperational(), + lastUpdate: readinessManager.lastUpdate(), + }; + } return { readinessManager, @@ -134,17 +145,9 @@ export function sdkReadinessManagerFactory( }); }, - getStatus() { - return { - isReady: readinessManager.isReady(), - isReadyFromCache: readinessManager.isReadyFromCache(), - isTimedout: readinessManager.isTimedout(), - hasTimedout: readinessManager.hasTimedout(), - isDestroyed: readinessManager.isDestroyed(), - isOperational: readinessManager.isOperational(), - lastUpdate: readinessManager.lastUpdate(), - }; - }, + getStatus, + // @TODO: remove in next major + __getStatus: getStatus } ) }; From f4145a969abd49f1ea3388278dba03cf2f353dfe Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Tue, 28 Oct 2025 15:02:11 -0300 Subject: [PATCH 23/35] stable version --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7284cf89..a6093d8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@splitsoftware/splitio-commons", - "version": "2.7.9-rc.1", + "version": "2.8.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@splitsoftware/splitio-commons", - "version": "2.7.9-rc.1", + "version": "2.8.0", "license": "Apache-2.0", "dependencies": { "@types/ioredis": "^4.28.0", diff --git a/package.json b/package.json index ed693bcd..ab0a1d2f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@splitsoftware/splitio-commons", - "version": "2.7.9-rc.1", + "version": "2.8.0", "description": "Split JavaScript SDK common components", "main": "cjs/index.js", "module": "esm/index.js", From ffa0053faf14ad4cfe0fe2064cd72b3a886df7ca Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Tue, 28 Oct 2025 15:55:47 -0300 Subject: [PATCH 24/35] rc --- package-lock.json | 4 ++-- package.json | 2 +- src/evaluator/fallbackTreatmentsCalculator/constants.ts | 4 ---- .../fallbackTreatmentsCalculator/fallbackSanitizer/index.ts | 5 ++++- src/logger/messages/info.ts | 2 +- src/logger/messages/warn.ts | 2 +- 6 files changed, 9 insertions(+), 10 deletions(-) delete mode 100644 src/evaluator/fallbackTreatmentsCalculator/constants.ts diff --git a/package-lock.json b/package-lock.json index a6093d8d..6401ff9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@splitsoftware/splitio-commons", - "version": "2.8.0", + "version": "2.7.9-rc.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@splitsoftware/splitio-commons", - "version": "2.8.0", + "version": "2.7.9-rc.2", "license": "Apache-2.0", "dependencies": { "@types/ioredis": "^4.28.0", diff --git a/package.json b/package.json index ab0a1d2f..2dc04f72 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@splitsoftware/splitio-commons", - "version": "2.8.0", + "version": "2.7.9-rc.2", "description": "Split JavaScript SDK common components", "main": "cjs/index.js", "module": "esm/index.js", diff --git a/src/evaluator/fallbackTreatmentsCalculator/constants.ts b/src/evaluator/fallbackTreatmentsCalculator/constants.ts deleted file mode 100644 index 292bae0f..00000000 --- a/src/evaluator/fallbackTreatmentsCalculator/constants.ts +++ /dev/null @@ -1,4 +0,0 @@ -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 index 66ec65f3..dbeabe88 100644 --- a/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts +++ b/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts @@ -1,8 +1,11 @@ import { Treatment, TreatmentWithConfig } from '../../../../types/splitio'; import { ILogger } from '../../../logger/types'; import { isObject, isString } from '../../../utils/lang'; -import { FallbackDiscardReason } from '../constants'; +enum FallbackDiscardReason { + FlagName = 'Invalid flag name (max 100 chars, no spaces)', + Treatment = 'Invalid treatment (max 100 chars and must match pattern)', +} export class FallbacksSanitizer { diff --git a/src/logger/messages/info.ts b/src/logger/messages/info.ts index fb017250..1e9b5f0d 100644 --- a/src/logger/messages/info.ts +++ b/src/logger/messages/info.ts @@ -22,7 +22,7 @@ export const codesInfo: [number, string][] = codesWarn.concat([ [c.POLLING_SMART_PAUSING, c.LOG_PREFIX_SYNC_POLLING + 'Turning segments data polling %s.'], [c.POLLING_START, c.LOG_PREFIX_SYNC_POLLING + 'Starting polling'], [c.POLLING_STOP, c.LOG_PREFIX_SYNC_POLLING + 'Stopping polling'], - [c.SYNC_SPLITS_FETCH_RETRY, c.LOG_PREFIX_SYNC_SPLITS + 'Retrying download of feature flags #%s. Reason: %s'], + [c.SYNC_SPLITS_FETCH_RETRY, c.LOG_PREFIX_SYNC_SPLITS + 'Retrying fetch of feature flags (attempt #%s). Reason: %s'], [c.SUBMITTERS_PUSH_FULL_QUEUE, c.LOG_PREFIX_SYNC_SUBMITTERS + 'Flushing full %s queue and resetting timer.'], [c.SUBMITTERS_PUSH, c.LOG_PREFIX_SYNC_SUBMITTERS + 'Pushing %s.'], [c.SUBMITTERS_PUSH_PAGE_HIDDEN, c.LOG_PREFIX_SYNC_SUBMITTERS + 'Flushing %s because page became hidden.'], diff --git a/src/logger/messages/warn.ts b/src/logger/messages/warn.ts index a0bf31a9..4bd74dd6 100644 --- a/src/logger/messages/warn.ts +++ b/src/logger/messages/warn.ts @@ -6,7 +6,7 @@ export const codesWarn: [number, string][] = codesError.concat([ [c.ENGINE_VALUE_INVALID, c.LOG_PREFIX_ENGINE_VALUE + 'Value %s doesn\'t match with expected type.'], [c.ENGINE_VALUE_NO_ATTRIBUTES, c.LOG_PREFIX_ENGINE_VALUE + 'Defined attribute `%s`. No attributes received.'], // synchronizer - [c.SYNC_MYSEGMENTS_FETCH_RETRY, c.LOG_PREFIX_SYNC_MYSEGMENTS + 'Retrying download of segments #%s. Reason: %s'], + [c.SYNC_MYSEGMENTS_FETCH_RETRY, c.LOG_PREFIX_SYNC_MYSEGMENTS + 'Retrying fetch of memberships (attempt #%s). Reason: %s'], [c.SYNC_SPLITS_FETCH_FAILS, c.LOG_PREFIX_SYNC_SPLITS + 'Error while doing fetch of feature flags. %s'], [c.STREAMING_PARSING_ERROR_FAILS, c.LOG_PREFIX_SYNC_STREAMING + 'Error parsing SSE error notification: %s'], [c.STREAMING_PARSING_MESSAGE_FAILS, c.LOG_PREFIX_SYNC_STREAMING + 'Error parsing SSE message notification: %s'], From 274fbd6e36e143ee2be54a2f0d4165d873d5baa5 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Tue, 28 Oct 2025 16:02:21 -0300 Subject: [PATCH 25/35] stable version --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6401ff9a..a6093d8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@splitsoftware/splitio-commons", - "version": "2.7.9-rc.2", + "version": "2.8.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@splitsoftware/splitio-commons", - "version": "2.7.9-rc.2", + "version": "2.8.0", "license": "Apache-2.0", "dependencies": { "@types/ioredis": "^4.28.0", diff --git a/package.json b/package.json index 2dc04f72..ab0a1d2f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@splitsoftware/splitio-commons", - "version": "2.7.9-rc.2", + "version": "2.8.0", "description": "Split JavaScript SDK common components", "main": "cjs/index.js", "module": "esm/index.js", From 16eb793ed74f0ffebd85b07bf75517e2fd964d14 Mon Sep 17 00:00:00 2001 From: Emmanuel Zamora Date: Wed, 29 Oct 2025 13:42:32 -0300 Subject: [PATCH 26/35] add fallbackTreatments to shared settings --- types/splitio.d.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/types/splitio.d.ts b/types/splitio.d.ts index a2903c27..0bd5129a 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -96,6 +96,10 @@ 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. + */ + fallbackTreatments?: SplitIO.FallbackTreatmentConfiguration; } /** * Common settings properties for SDKs with synchronous API (standalone and localhost modes). From 501174de5cdb5281b4aee997c279fad009c39390 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Wed, 29 Oct 2025 13:50:08 -0300 Subject: [PATCH 27/35] Update changelog entry date --- CHANGES.txt | 2 +- types/splitio.d.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index 6da0984c..a81cb5e0 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,4 +1,4 @@ -2.8.0 (October 28, 2025) +2.8.0 (October 29, 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/types/splitio.d.ts b/types/splitio.d.ts index 0bd5129a..89e59b09 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -717,7 +717,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; From 9474cffdb13fe723c8beb6ac145fa4278da8dc28 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Wed, 29 Oct 2025 16:13:13 -0300 Subject: [PATCH 28/35] refactor: apply fallback sanitization during settings validation --- .../fallbackSanitizer/index.ts | 91 ++++++++++--------- .../fallbackTreatmentsCalculator/index.ts | 13 +-- src/sdkFactory/index.ts | 2 +- src/utils/settingsValidation/index.ts | 4 + 4 files changed, 53 insertions(+), 57 deletions(-) diff --git a/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts b/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts index dbeabe88..db84af64 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,62 @@ 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(' '); - } +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; +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 (!isValidTreatment(treatment)) { + logger.error(`Fallback treatments - Discarded fallback: ${FallbackDiscardReason.Treatment}`); + return undefined; } + return treatment; +} + +function sanitizeByFlag( + logger: ILogger, + byFlagFallbacks: Record = {} +): Record { + const sanitizedByFlag: Record = {}; - static sanitizeByFlag( - logger: ILogger, - byFlagFallbacks: Record - ): Record { - const sanitizedByFlag: Record = {}; + const entries = Object.keys(byFlagFallbacks); + entries.forEach((flag) => { + const t = byFlagFallbacks[flag]; + if (!isValidFlagName(flag)) { + logger.error(`Fallback treatments - Discarded flag '${flag}': ${FallbackDiscardReason.FlagName}`); + return; + } - 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 (!isValidTreatment(t)) { + logger.error(`Fallback treatments - Discarded treatment for flag '${flag}': ${FallbackDiscardReason.Treatment}`); + return; + } - if (!this.isValidTreatment(t)) { - logger.error( - `Fallback treatments - Discarded treatment for flag '${flag}': ${FallbackDiscardReason.Treatment}` - ); - return; - } + sanitizedByFlag[flag] = t; + }); - sanitizedByFlag[flag] = t; - }); + return sanitizedByFlag; +} - return sanitizedByFlag; +export function sanitizeFallbacks(logger: ILogger, fallbacks: unknown): FallbackTreatmentConfiguration | undefined { + if (!isObject(fallbacks)) { + logger.error('Fallback treatments - Discarded fallback: Invalid fallback configuration'); + return; } + + return { + global: sanitizeGlobal(logger, (fallbacks as FallbackTreatmentConfiguration).global), + byFlag: sanitizeByFlag(logger, (fallbacks as FallbackTreatmentConfiguration).byFlag) + }; } diff --git a/src/evaluator/fallbackTreatmentsCalculator/index.ts b/src/evaluator/fallbackTreatmentsCalculator/index.ts index 7921f72c..87b49421 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 }; @@ -11,16 +9,7 @@ export type IFallbackTreatmentsCalculator = { 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(private readonly fallbacks: FallbackTreatmentConfiguration = {}) {} resolve(flagName: string, label: string): TreatmentWithConfig & { label: string } { const treatment = this.fallbacks.byFlag?.[flagName]; 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; } From d3c8665116c3e5e8d5de0760c881be70521a96f5 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Wed, 29 Oct 2025 16:49:23 -0300 Subject: [PATCH 29/35] Update error message --- .../fallbackTreatmentsCalculator/fallbackSanitizer/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts b/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts index db84af64..0438191b 100644 --- a/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts +++ b/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts @@ -57,7 +57,7 @@ function sanitizeByFlag( export function sanitizeFallbacks(logger: ILogger, fallbacks: unknown): FallbackTreatmentConfiguration | undefined { if (!isObject(fallbacks)) { - logger.error('Fallback treatments - Discarded fallback: Invalid fallback configuration'); + logger.error('Fallback treatments - Discarded configuration: it must be an object with optional `global` and `byFlag` properties'); return; } From f2ac5d65ba3d029afd8efbd1fd7fa3ecc1d66384 Mon Sep 17 00:00:00 2001 From: Emmanuel Zamora Date: Wed, 29 Oct 2025 18:27:54 -0300 Subject: [PATCH 30/35] Fix tests - add type validations --- .../__tests__/fallback-calculator.spec.ts | 87 +------------------ .../__tests__/fallback-sanitizer.spec.ts | 76 +++++++++++----- .../fallbackSanitizer/index.ts | 6 +- .../__tests__/sdkClientMethod.spec.ts | 4 +- src/sdkFactory/__tests__/index.spec.ts | 2 +- 5 files changed, 65 insertions(+), 110 deletions(-) diff --git a/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts index 6240e2a0..e257d746 100644 --- a/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts +++ b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts @@ -6,94 +6,13 @@ 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 +27,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 +41,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..ab637f8a 100644 --- a/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts +++ b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts @@ -1,10 +1,14 @@ -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(() => {}); @@ -17,49 +21,54 @@ describe('FallbacksSanitizer', () => { 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((FallbacksSanitizer as any).isValidFlagName('invalid flag')).toBe(false); + expect(isValidFlagName('invalid flag')).toBe(false); + }); + + test('returns false if the name contains spaces', () => { + // @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 +83,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 +94,45 @@ 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', () => { + 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', () => { + 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', () => { + 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 0438191b..86d34d52 100644 --- a/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts +++ b/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts @@ -9,11 +9,11 @@ enum FallbackDiscardReason { const TREATMENT_PATTERN = /^[0-9]+[.a-zA-Z0-9_-]*$|^[a-zA-Z]+[a-zA-Z0-9_-]*$/; -function isValidFlagName(name: string): boolean { +export function isValidFlagName(name: string): boolean { return name.length <= 100 && !name.includes(' '); } -function isValidTreatment(t?: Treatment | TreatmentWithConfig): boolean { +export function isValidTreatment(t?: Treatment | TreatmentWithConfig): boolean { const treatment = isObject(t) ? (t as TreatmentWithConfig).treatment : t; if (!isString(treatment) || treatment.length > 100) { @@ -23,6 +23,7 @@ function isValidTreatment(t?: Treatment | TreatmentWithConfig): boolean { } 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; @@ -39,6 +40,7 @@ function sanitizeByFlag( const entries = Object.keys(byFlagFallbacks); entries.forEach((flag) => { const t = byFlagFallbacks[flag]; + if (!t) return; if (!isValidFlagName(flag)) { logger.error(`Fallback treatments - Discarded flag '${flag}': ${FallbackDiscardReason.FlagName}`); return; 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() }; From 26fe9fbbf7733fdcac1157880509dcbb29d4f69c Mon Sep 17 00:00:00 2001 From: Emmanuel Zamora Date: Wed, 29 Oct 2025 18:30:51 -0300 Subject: [PATCH 31/35] Fix lint --- .../__tests__/fallback-calculator.spec.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts index e257d746..2461bff6 100644 --- a/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts +++ b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts @@ -1,11 +1,8 @@ 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('returns specific fallback if flag exists', () => { const config: FallbackTreatmentConfiguration = { byFlag: { From ee50293b833e962c689d47eed9a82683c7e22ab2 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Thu, 30 Oct 2025 11:27:48 -0300 Subject: [PATCH 32/35] refactor: simplify fallback treatment sanitization and type handling --- .../__tests__/fallback-sanitizer.spec.ts | 13 +++++-------- .../fallbackSanitizer/index.ts | 17 +++++++++-------- .../fallbackTreatmentsCalculator/index.ts | 6 +++++- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts index ab637f8a..27c0ab7a 100644 --- a/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts +++ b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts @@ -11,11 +11,7 @@ describe('FallbacksSanitizer', () => { }; beforeEach(() => { - jest.spyOn(console, 'error').mockImplementation(() => {}); - }); - - afterEach(() => { - (loggerMock.error as jest.Mock).mockRestore(); + loggerMock.mockClear(); }); describe('isValidFlagName', () => { @@ -113,8 +109,9 @@ describe('FallbacksSanitizer', () => { expect(loggerMock.error).not.toHaveBeenCalled(); }); }); + describe('sanitizeFallbacks', () => { - test('returns undefined and logs error if fallbacks is not an object', () => { + 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( @@ -122,7 +119,7 @@ describe('FallbacksSanitizer', () => { ); }); - test('returns undefined and logs error if fallbacks is not an object', () => { + 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( @@ -130,7 +127,7 @@ describe('FallbacksSanitizer', () => { ); }); - test('sanitizes both global and byFlag fallbacks for empty object', () => { + 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 86d34d52..95ac25e1 100644 --- a/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts +++ b/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts @@ -33,14 +33,15 @@ function sanitizeGlobal(logger: ILogger, treatment?: Treatment | TreatmentWithCo function sanitizeByFlag( logger: ILogger, - byFlagFallbacks: Record = {} + byFlagFallbacks?: Record ): Record { const sanitizedByFlag: Record = {}; - const entries = Object.keys(byFlagFallbacks); - entries.forEach((flag) => { - const t = byFlagFallbacks[flag]; - if (!t) return; + if (!isObject(byFlagFallbacks)) return sanitizedByFlag; + + Object.keys(byFlagFallbacks!).forEach((flag) => { + const t = byFlagFallbacks![flag]; + if (!isValidFlagName(flag)) { logger.error(`Fallback treatments - Discarded flag '${flag}': ${FallbackDiscardReason.FlagName}`); return; @@ -57,14 +58,14 @@ function sanitizeByFlag( return sanitizedByFlag; } -export function sanitizeFallbacks(logger: ILogger, fallbacks: unknown): FallbackTreatmentConfiguration | undefined { +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 as FallbackTreatmentConfiguration).global), - byFlag: sanitizeByFlag(logger, (fallbacks as FallbackTreatmentConfiguration).byFlag) + 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 87b49421..e582aa97 100644 --- a/src/evaluator/fallbackTreatmentsCalculator/index.ts +++ b/src/evaluator/fallbackTreatmentsCalculator/index.ts @@ -9,7 +9,11 @@ export type IFallbackTreatmentsCalculator = { export const FALLBACK_PREFIX = 'fallback - '; export class FallbackTreatmentsCalculator implements IFallbackTreatmentsCalculator { - constructor(private readonly fallbacks: FallbackTreatmentConfiguration = {}) {} + private readonly fallbacks: FallbackTreatmentConfiguration; + + constructor(fallbacks: FallbackTreatmentConfiguration = {}) { + this.fallbacks = fallbacks; + } resolve(flagName: string, label: string): TreatmentWithConfig & { label: string } { const treatment = this.fallbacks.byFlag?.[flagName]; From a6295922402aa82faaf004f5324202bb616d6e00 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Thu, 30 Oct 2025 13:39:52 -0300 Subject: [PATCH 33/35] ci: pin node version to 22 temporarily in GitHub Actions workflow --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 From 5ae4013f02667ae29291528f67d85ac176d974e1 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Thu, 30 Oct 2025 16:22:06 -0300 Subject: [PATCH 34/35] docs: add JSDoc comments for fallback treatment configuration types --- types/splitio.d.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/types/splitio.d.ts b/types/splitio.d.ts index 89e59b09..bd7da67c 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -98,6 +98,8 @@ interface ISharedSettings { 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; } @@ -1307,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 } From 1f025706f5347bc70ff88fdca072344901441e9a Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Thu, 30 Oct 2025 16:23:39 -0300 Subject: [PATCH 35/35] Update changelog entry date --- CHANGES.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES.txt b/CHANGES.txt index a81cb5e0..4c71ee9f 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,4 +1,4 @@ -2.8.0 (October 29, 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.