diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b4cd7bc..c2128f2b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,8 @@ jobs: - name: Set up nodejs uses: actions/setup-node@v4 with: - node-version: 'lts/*' + # @TODO: rollback to 'lts/*' + node-version: '22' cache: 'npm' - name: npm CI diff --git a/CHANGES.txt b/CHANGES.txt index 2b93b7fc..4c71ee9f 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,9 @@ +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. + - 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/package-lock.json b/package-lock.json index 356a4e93..a6093d8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@splitsoftware/splitio-commons", - "version": "2.7.1", + "version": "2.8.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@splitsoftware/splitio-commons", - "version": "2.7.1", + "version": "2.8.0", "license": "Apache-2.0", "dependencies": { "@types/ioredis": "^4.28.0", diff --git a/package.json b/package.json index 30ea1784..ab0a1d2f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@splitsoftware/splitio-commons", - "version": "2.7.1", + "version": "2.8.0", "description": "Split JavaScript SDK common components", "main": "cjs/index.js", "module": "esm/index.js", diff --git a/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts new file mode 100644 index 00000000..2461bff6 --- /dev/null +++ b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts @@ -0,0 +1,50 @@ +import { FallbackTreatmentsCalculator } from '../'; +import type { FallbackTreatmentConfiguration } from '../../../../types/splitio'; +import { CONTROL } from '../../../utils/constants'; + +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: 'label by noFallback', + }); + }); +}); diff --git a/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts new file mode 100644 index 00000000..27c0ab7a --- /dev/null +++ b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts @@ -0,0 +1,135 @@ +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(() => { + loggerMock.mockClear(); + }); + + describe('isValidFlagName', () => { + test('returns true for a valid flag name', () => { + // @ts-expect-private-access + expect(isValidFlagName('my_flag')).toBe(true); + }); + + test('returns false for a name longer than 100 chars', () => { + const longName = 'a'.repeat(101); + expect(isValidFlagName(longName)).toBe(false); + }); + + test('returns false if the name contains spaces', () => { + expect(isValidFlagName('invalid flag')).toBe(false); + }); + + test('returns false if the name contains spaces', () => { + // @ts-ignore + expect(isValidFlagName(true)).toBe(false); + }); + }); + + describe('isValidTreatment', () => { + test('returns true for a valid treatment string', () => { + expect(isValidTreatment(validTreatment)).toBe(true); + }); + + test('returns false for null or undefined', () => { + 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), config: null }; + expect(isValidTreatment(long)).toBe(false); + }); + + test('returns false if treatment does not match regex pattern', () => { + const invalid = { treatment: 'invalid treatment!', config: null }; + expect(isValidTreatment(invalid)).toBe(false); + }); + }); + + describe('sanitizeGlobal', () => { + test('returns the treatment if valid', () => { + 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 = sanitizeFallbacks(loggerMock, { ...fallbackMock, global: invalidTreatment }); + expect(result).toEqual(fallbackMock); + expect(loggerMock.error).toHaveBeenCalledWith( + expect.stringContaining('Fallback treatments - Discarded fallback') + ); + }); + }); + + describe('sanitizeByFlag', () => { + test('returns a sanitized map with valid entries only', () => { + const input = { + valid_flag: validTreatment, + 'invalid flag': validTreatment, + bad_treatment: invalidTreatment, + }; + + const result = sanitizeFallbacks(loggerMock, {...fallbackMock, byFlag: input}); + + expect(result).toEqual({ ...fallbackMock, byFlag: { valid_flag: validTreatment } }); + expect(loggerMock.error).toHaveBeenCalledTimes(2); // invalid flag + bad_treatment + }); + + test('returns empty object if all invalid', () => { + const input = { + 'invalid flag': invalidTreatment, + }; + + const result = sanitizeFallbacks(loggerMock, {...fallbackMock, byFlag: input}); + expect(result).toEqual(fallbackMock); + expect(loggerMock.error).toHaveBeenCalled(); + }); + + test('returns same object if all valid', () => { + const input = { + ...fallbackMock, + byFlag:{ + flag_one: validTreatment, + flag_two: { treatment: 'valid_2', config: null }, + } + }; + + const result = sanitizeFallbacks(loggerMock, input); + expect(result).toEqual(input); + expect(loggerMock.error).not.toHaveBeenCalled(); + }); + }); + + describe('sanitizeFallbacks', () => { + test('returns undefined and logs error if fallbacks is not an object', () => { // @ts-expect-error + const result = sanitizeFallbacks(loggerMock, 'invalid_fallbacks'); + expect(result).toBeUndefined(); + expect(loggerMock.error).toHaveBeenCalledWith( + 'Fallback treatments - Discarded configuration: it must be an object with optional `global` and `byFlag` properties' + ); + }); + + test('returns undefined and logs error if fallbacks is not an object', () => { // @ts-expect-error + const result = sanitizeFallbacks(loggerMock, true); + expect(result).toBeUndefined(); + expect(loggerMock.error).toHaveBeenCalledWith( + 'Fallback treatments - Discarded configuration: it must be an object with optional `global` and `byFlag` properties' + ); + }); + + test('sanitizes both global and byFlag fallbacks for empty object', () => { // @ts-expect-error + const result = sanitizeFallbacks(loggerMock, { global: {} }); + expect(result).toEqual({ global: undefined, byFlag: {} }); + }); + }); +}); diff --git a/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts b/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts new file mode 100644 index 00000000..95ac25e1 --- /dev/null +++ b/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts @@ -0,0 +1,71 @@ +import { FallbackTreatmentConfiguration, Treatment, TreatmentWithConfig } from '../../../../types/splitio'; +import { ILogger } from '../../../logger/types'; +import { isObject, isString } from '../../../utils/lang'; + +enum FallbackDiscardReason { + FlagName = 'Invalid flag name (max 100 chars, no spaces)', + Treatment = 'Invalid treatment (max 100 chars and must match pattern)', +} + +const TREATMENT_PATTERN = /^[0-9]+[.a-zA-Z0-9_-]*$|^[a-zA-Z]+[a-zA-Z0-9_-]*$/; + +export function isValidFlagName(name: string): boolean { + return name.length <= 100 && !name.includes(' '); +} + +export function isValidTreatment(t?: Treatment | TreatmentWithConfig): boolean { + const treatment = isObject(t) ? (t as TreatmentWithConfig).treatment : t; + + if (!isString(treatment) || treatment.length > 100) { + return false; + } + return TREATMENT_PATTERN.test(treatment); +} + +function sanitizeGlobal(logger: ILogger, treatment?: Treatment | TreatmentWithConfig): Treatment | TreatmentWithConfig | undefined { + if (treatment === undefined) return undefined; + if (!isValidTreatment(treatment)) { + logger.error(`Fallback treatments - Discarded fallback: ${FallbackDiscardReason.Treatment}`); + return undefined; + } + return treatment; +} + +function sanitizeByFlag( + logger: ILogger, + byFlagFallbacks?: Record +): Record { + const sanitizedByFlag: Record = {}; + + 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; + } + + if (!isValidTreatment(t)) { + logger.error(`Fallback treatments - Discarded treatment for flag '${flag}': ${FallbackDiscardReason.Treatment}`); + return; + } + + sanitizedByFlag[flag] = t; + }); + + return sanitizedByFlag; +} + +export function sanitizeFallbacks(logger: ILogger, fallbacks: FallbackTreatmentConfiguration): FallbackTreatmentConfiguration | undefined { + if (!isObject(fallbacks)) { + logger.error('Fallback treatments - Discarded configuration: it must be an object with optional `global` and `byFlag` properties'); + return; + } + + return { + global: sanitizeGlobal(logger, fallbacks.global), + byFlag: sanitizeByFlag(logger, fallbacks.byFlag) + }; +} diff --git a/src/evaluator/fallbackTreatmentsCalculator/index.ts b/src/evaluator/fallbackTreatmentsCalculator/index.ts new file mode 100644 index 00000000..e582aa97 --- /dev/null +++ b/src/evaluator/fallbackTreatmentsCalculator/index.ts @@ -0,0 +1,50 @@ +import { FallbackTreatmentConfiguration, Treatment, TreatmentWithConfig } from '../../../types/splitio'; +import { CONTROL } from '../../utils/constants'; +import { isString } from '../../utils/lang'; + +export type IFallbackTreatmentsCalculator = { + resolve(flagName: string, label: string): TreatmentWithConfig & { label: string }; +} + +export const FALLBACK_PREFIX = 'fallback - '; + +export class FallbackTreatmentsCalculator implements IFallbackTreatmentsCalculator { + private readonly fallbacks: FallbackTreatmentConfiguration; + + constructor(fallbacks: FallbackTreatmentConfiguration = {}) { + this.fallbacks = fallbacks; + } + + resolve(flagName: string, label: string): TreatmentWithConfig & { label: string } { + const treatment = this.fallbacks.byFlag?.[flagName]; + if (treatment) { + return this.copyWithLabel(treatment, label); + } + + if (this.fallbacks.global) { + return this.copyWithLabel(this.fallbacks.global, label); + } + + return { + treatment: CONTROL, + config: null, + label, + }; + } + + private copyWithLabel(fallback: Treatment | TreatmentWithConfig, label: string): TreatmentWithConfig & { label: string } { + if (isString(fallback)) { + return { + treatment: fallback, + config: null, + label: `${FALLBACK_PREFIX}${label}`, + }; + } + + return { + treatment: fallback.treatment, + config: fallback.config, + label: `${FALLBACK_PREFIX}${label}`, + }; + } +} diff --git a/src/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/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 81cfda1a..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'], @@ -14,8 +14,8 @@ 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_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_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_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 9044fc72..5861942f 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, 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'; import { fullSettings } from '../../utils/settingsValidation/__tests__/settings.mocks'; +import { EventEmitter } from '../../utils/MinEvents'; const EventEmitterMock = jest.fn(() => ({ on: jest.fn(), @@ -19,24 +20,37 @@ 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](); 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.'; // 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 } 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. @@ -50,9 +64,9 @@ 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.__getStatus).toBe('function'); // The sdkStatus exposes a .__getStatus() function. - expect(sdkStatus.__getStatus()).toEqual({ + 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(sdkStatus.getStatus()).toEqual({ // The sdkStatus exposes a .getStatus() function. isReady: false, isReadyFromCache: false, isTimedout: false, hasTimedout: false, isDestroyed: false, isOperational: false, lastUpdate: 0 }); @@ -67,9 +81,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 +112,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,77 +213,98 @@ describe('SDK Readiness Manager - Event emitter', () => { }); }); -describe('SDK Readiness Manager - Ready promise', () => { +describe('SDK Readiness Manager - Promises', () => { - test('.ready() 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); - const ready = sdkReadinessManager.sdkStatus.ready(); - expect(ready instanceof Promise).toBe(true); // It should return a promise. + // 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']); - // make the SDK "ready" + const readyFromCache = sdkReadinessManager.sdkStatus.whenReadyFromCache(); + const ready = sdkReadinessManager.sdkStatus.whenReady(); + + // 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 .ready() must be a resolved promise - await ready.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); + }); - const readyForTimeout = sdkReadinessManagerForTimedout.sdkStatus.ready(); + test('.whenReady() and .whenReadyFromCache() promises reject when SDK_READY_TIMED_OUT is emitted before SDK_READY', async () => { + const sdkReadinessManagerForTimedout = sdkReadinessManagerFactory(EventEmitter, fullSettings); - emitTimeoutEvent(sdkReadinessManagerForTimedout.readinessManager); // make the SDK "timed out" + const readyFromCacheForTimeout = sdkReadinessManagerForTimedout.sdkStatus.whenReadyFromCache(); + const readyForTimeout = sdkReadinessManagerForTimedout.sdkStatus.whenReady(); - 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++; - } - ); + emitTimeoutEvent(sdkReadinessManagerForTimedout.readinessManager); // make the SDK timeout - // any subsequent call to .ready() must be a rejected promise - await readyForTimeout.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++; - } - ); + let testPassedCount = 0; + function incTestPassedCount() { testPassedCount++; } + function throwTestFailed() { throw new Error('It should rejected, not resolved.'); } + + await readyFromCacheForTimeout.then(throwTestFailed,incTestPassedCount); + await readyForTimeout.then(throwTestFailed,incTestPassedCount); - // make the SDK "ready" + // 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, `.ready()` returns a resolved promise - await ready.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.'); } - ); + // once SDK_READY, `.whenReady()` returns a resolved promise + await sdkReadinessManagerForTimedout.sdkStatus.whenReady().then(incTestPassedCount, throwTestFailed); + await sdkReadinessManagerForTimedout.sdkStatus.whenReadyFromCache().then(incTestPassedCount, throwTestFailed); + + expect(testPassedCount).toBe(6); + }); + + test('whenReady promise counts as an SDK_READY listener', (done) => { + let sdkReadinessManager = sdkReadinessManagerFactory(EventEmitter, fullSettings); + + 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(); + + 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.'); + }); + + emitReadyEvent(sdkReadinessManager.readinessManager); + + 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', () => { + + beforeEach(() => { loggerMock.mockClear(); }); - test('Full blown ready promise count as a callback and resolves on SDK_READY', (done) => { + 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/readinessManager.ts b/src/readiness/readinessManager.ts index c69eedce..319e843d 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(), { @@ -91,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); @@ -115,9 +114,9 @@ export function readinessManagerFactory( isReady = true; try { syncLastUpdate(); - if (!isReadyFromCache && settings.storage?.type === STORAGE_LOCALSTORAGE) { + 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/src/readiness/sdkReadinessManager.ts b/src/readiness/sdkReadinessManager.ts index ee558d47..d3b841de 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(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). @@ -38,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']); } }); @@ -69,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, @@ -93,6 +107,7 @@ export function sdkReadinessManagerFactory( SDK_READY_TIMED_OUT, }, + // @TODO: remove in next major ready() { if (readinessManager.hasTimedout()) { if (!readinessManager.isReady()) { @@ -104,17 +119,35 @@ export function sdkReadinessManagerFactory( return readyPromise; }, - __getStatus() { - return { - isReady: readinessManager.isReady(), - isReadyFromCache: readinessManager.isReadyFromCache(), - isTimedout: readinessManager.isTimedout(), - hasTimedout: readinessManager.hasTimedout(), - isDestroyed: readinessManager.isDestroyed(), - isOperational: readinessManager.isOperational(), - lastUpdate: readinessManager.lastUpdate(), - }; + 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(readinessManager.isReady()); + } else if (readinessManager.hasTimedout()) { + reject(TIMEOUT_ERROR); + } else { + readinessManager.gate.once(SDK_READY_FROM_CACHE, () => resolve(readinessManager.isReady())); + readinessManager.gate.once(SDK_READY_TIMED_OUT, () => reject(TIMEOUT_ERROR)); + } + }); }, + + getStatus, + // @TODO: remove in next major + __getStatus: getStatus } ) }; 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/sdkClient/__tests__/clientInputValidation.spec.ts b/src/sdkClient/__tests__/clientInputValidation.spec.ts index f70845f7..f2f5648f 100644 --- a/src/sdkClient/__tests__/clientInputValidation.spec.ts +++ b/src/sdkClient/__tests__/clientInputValidation.spec.ts @@ -4,6 +4,7 @@ import { clientInputValidationDecorator } from '../clientInputValidation'; // Mocks import { DebugLogger } from '../../logger/browser/DebugLogger'; import { createClientMock } from './testUtils'; +import { FallbackTreatmentsCalculator, IFallbackTreatmentsCalculator } from '../../evaluator/fallbackTreatmentsCalculator'; const settings: any = { log: DebugLogger(), @@ -13,13 +14,15 @@ const settings: any = { const EVALUATION_RESULT = 'on'; const client: any = createClientMock(EVALUATION_RESULT); +const fallbackTreatmentsCalculator: IFallbackTreatmentsCalculator = new FallbackTreatmentsCalculator(settings); + const readinessManager: any = { - isReady: () => true, + isReadyFromCache: () => true, isDestroyed: () => false }; describe('clientInputValidationDecorator', () => { - const clientWithValidation = clientInputValidationDecorator(settings, client, readinessManager); + const clientWithValidation = clientInputValidationDecorator(settings, client, readinessManager, fallbackTreatmentsCalculator); const logSpy = jest.spyOn(console, 'log'); beforeEach(() => { diff --git a/src/sdkClient/__tests__/sdkClientMethod.spec.ts b/src/sdkClient/__tests__/sdkClientMethod.spec.ts index e2f53f83..9120f20e 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({}) }, // 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({}) } ]; diff --git a/src/sdkClient/client.ts b/src/sdkClient/client.ts index 0e526f72..9a7642e6 100644 --- a/src/sdkClient/client.ts +++ b/src/sdkClient/client.ts @@ -35,7 +35,7 @@ function stringify(options?: SplitIO.EvaluationOptions) { * Creator of base client with getTreatments and track methods. */ export function clientFactory(params: ISdkFactoryContext): SplitIO.IClient | SplitIO.IAsyncClient { - const { sdkReadinessManager: { readinessManager }, storage, settings, impressionsTracker, eventTracker, telemetryTracker } = params; + const { sdkReadinessManager: { readinessManager }, storage, settings, impressionsTracker, eventTracker, telemetryTracker, fallbackTreatmentsCalculator } = params; const { log, mode } = settings; const isAsync = isConsumerMode(mode); @@ -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({}) : @@ -143,7 +143,16 @@ export function clientFactory(params: ISdkFactoryContext): SplitIO.IClient | Spl const matchingKey = getMatching(key); const bucketingKey = getBucketing(key); - const { treatment, label, changeNumber, config = null, impressionsDisabled } = evaluation; + const { changeNumber, impressionsDisabled } = evaluation; + let { treatment, label, config = null } = evaluation; + + if (treatment === CONTROL) { + const fallbackTreatment = fallbackTreatmentsCalculator.resolve(featureFlagName, label); + treatment = fallbackTreatment.treatment; + label = fallbackTreatment.label; + config = fallbackTreatment.config; + } + log.info(IMPRESSION, [featureFlagName, matchingKey, treatment, label]); if (validateSplitExistence(log, readinessManager, featureFlagName, label, invokingMethodName)) { diff --git a/src/sdkClient/clientInputValidation.ts b/src/sdkClient/clientInputValidation.ts index 40765d41..d0083feb 100644 --- a/src/sdkClient/clientInputValidation.ts +++ b/src/sdkClient/clientInputValidation.ts @@ -1,4 +1,3 @@ -import { objectAssign } from '../utils/lang/objectAssign'; import { validateAttributes, validateEvent, @@ -9,23 +8,24 @@ import { validateSplits, validateTrafficType, validateIfNotDestroyed, - validateIfOperational, + validateIfReadyFromCache, 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); @@ -46,7 +46,7 @@ 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..4b9f58ab 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() }; const SignalListenerInstanceMock = { start: jest.fn() }; diff --git a/src/sdkFactory/index.ts b/src/sdkFactory/index.ts index eba01028..02d3e0df 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.fallbackTreatments); + if (initialRolloutPlan) { setRolloutPlan(log, initialRolloutPlan, storage as IStorageSync, key && getMatching(key)); if ((storage as IStorageSync).splits.getChangeNumber() > -1) readiness.splits.emit(SDK_SPLITS_CACHE_LOADED); @@ -85,7 +88,7 @@ export function sdkFactory(params: ISdkFactoryParams): SplitIO.ISDK | SplitIO.IA // splitApi is used by SyncManager and Browser signal listener const splitApi = splitApiFactory && splitApiFactory(settings, platform, telemetryTracker); - const ctx: ISdkFactoryContext = { clients, splitApi, eventTracker, impressionsTracker, telemetryTracker, uniqueKeysTracker, sdkReadinessManager, readiness, settings, storage, platform }; + const ctx: ISdkFactoryContext = { clients, splitApi, eventTracker, impressionsTracker, telemetryTracker, uniqueKeysTracker, sdkReadinessManager, readiness, settings, storage, platform, fallbackTreatmentsCalculator }; const syncManager = syncManagerFactory && syncManagerFactory(ctx as ISdkFactoryContextSync); ctx.syncManager = syncManager; diff --git a/src/sdkFactory/types.ts b/src/sdkFactory/types.ts index 25882c38..00890c8e 100644 --- a/src/sdkFactory/types.ts +++ b/src/sdkFactory/types.ts @@ -3,6 +3,7 @@ import { ISignalListener } from '../listeners/types'; import { IReadinessManager, ISdkReadinessManager } from '../readiness/types'; import type { sdkManagerFactory } from '../sdkManager'; import type { splitApiFactory } from '../services/splitApi'; +import type { IFallbackTreatmentsCalculator } from '../evaluator/fallbackTreatmentsCalculator'; import { IFetch, ISplitApi, IEventSourceConstructor } from '../services/types'; import { IStorageAsync, IStorageSync, IStorageFactoryParams } from '../storages/types'; import { ISyncManager } from '../sync/types'; @@ -51,6 +52,7 @@ export interface ISdkFactoryContext { splitApi?: ISplitApi syncManager?: ISyncManager, clients: Record, + fallbackTreatmentsCalculator: IFallbackTreatmentsCalculator } export interface ISdkFactoryContextSync extends ISdkFactoryContext { diff --git a/src/sdkManager/__tests__/index.asyncCache.spec.ts b/src/sdkManager/__tests__/index.asyncCache.spec.ts index 429513d4..321cb776 100644 --- a/src/sdkManager/__tests__/index.asyncCache.spec.ts +++ b/src/sdkManager/__tests__/index.asyncCache.spec.ts @@ -15,6 +15,7 @@ import SplitIO from '../../../types/splitio'; const sdkReadinessManagerMock = { readinessManager: { isReady: jest.fn(() => 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): 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/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/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 82fba1c5..3cc19d31 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, @@ -72,7 +73,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); @@ -80,8 +81,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, @@ -119,7 +120,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 +132,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) { 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/src/utils/inputValidation/__tests__/isOperational.spec.ts b/src/utils/inputValidation/__tests__/isOperational.spec.ts index bbc6afa7..19c1373a 100644 --- a/src/utils/inputValidation/__tests__/isOperational.spec.ts +++ b/src/utils/inputValidation/__tests__/isOperational.spec.ts @@ -1,7 +1,7 @@ -import { CLIENT_NOT_READY, ERROR_CLIENT_DESTROYED } from '../../../logger/constants'; +import { CLIENT_NOT_READY_FROM_CACHE, ERROR_CLIENT_DESTROYED } from '../../../logger/constants'; import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock'; -import { validateIfNotDestroyed, validateIfOperational } from '../isOperational'; +import { validateIfNotDestroyed, validateIfReadyFromCache } from '../isOperational'; describe('validateIfNotDestroyed', () => { @@ -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..c8559b2a 100644 --- a/src/utils/inputValidation/splitExistence.ts +++ b/src/utils/inputValidation/splitExistence.ts @@ -1,15 +1,15 @@ -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'; /** * 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 (labelOrSplitObj === SPLIT_NOT_FOUND || labelOrSplitObj == null) { + 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 === FALLBACK_SPLIT_NOT_FOUND || labelOrSplitObj == null) { log.warn(WARN_NOT_EXISTENT_SPLIT, [method, splitName]); return false; } 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; +} 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/src/utils/settingsValidation/index.ts b/src/utils/settingsValidation/index.ts index 2dc63018..8f070082 100644 --- a/src/utils/settingsValidation/index.ts +++ b/src/utils/settingsValidation/index.ts @@ -8,6 +8,7 @@ import { ISettings } from '../../types'; import { validateKey } from '../inputValidation/key'; import { ERROR_MIN_CONFIG_PARAM, LOG_PREFIX_CLIENT_INSTANTIATION } from '../../logger/constants'; import { validateRolloutPlan } from '../../storages/setRolloutPlan'; +import { sanitizeFallbacks } from '../../evaluator/fallbackTreatmentsCalculator/fallbackSanitizer'; // Exported for telemetry export const base = { @@ -207,5 +208,8 @@ export function settingsValidation(config: unknown, validationParams: ISettingsV // @ts-ignore, modify readonly prop withDefaults.userConsent = consent ? consent(withDefaults) : undefined; + // @ts-ignore, modify readonly prop + withDefaults.fallbackTreatments = withDefaults.fallbackTreatments ? sanitizeFallbacks(log, withDefaults.fallbackTreatments) : undefined; + return withDefaults; } diff --git a/types/splitio.d.ts b/types/splitio.d.ts index 4062e012..bd7da67c 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -96,6 +96,12 @@ interface ISharedSettings { * Set together with `debug` option to `true` or a log level string to enable logging. */ logger?: SplitIO.Logger; + /** + * Fallback treatments to be used when the SDK is not ready or the flag is not found. + * + * @defaultValue `undefined` + */ + fallbackTreatments?: SplitIO.FallbackTreatmentConfiguration; } /** * Common settings properties for SDKs with synchronous API (standalone and localhost modes). @@ -525,19 +531,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'; }; @@ -618,6 +624,10 @@ declare namespace SplitIO { * User consent status if using in client-side. Undefined if using in server-side (Node.js). */ readonly userConsent?: ConsentStatus; + /** + * Fallback treatments to be used when the SDK is not ready or the flag is not found. + */ + readonly fallbackTreatments?: FallbackTreatmentConfiguration; } /** * Log levels. @@ -691,6 +701,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. + * 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. */ @@ -700,7 +756,13 @@ declare namespace SplitIO { */ Event: EventConsts; /** - * 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). + * Gets the readiness status. + * + * @returns The current readiness status. + */ + getStatus(): ReadinessStatus; + /** + * 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. @@ -715,8 +777,26 @@ 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 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 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. + */ + whenReadyFromCache(): Promise; } /** * Common definitions between clients for different environments interface. @@ -1225,6 +1305,21 @@ declare namespace SplitIO { * User consent status. */ type ConsentStatus = 'GRANTED' | 'DECLINED' | 'UNKNOWN'; + /** + * Fallback treatments to be used when the SDK is not ready or the flag is not found. + */ + type FallbackTreatmentConfiguration = { + /** + * 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 + } + } /** * Logger. Its interface details are not part of the public API. It shouldn't be used directly. */ @@ -1650,7 +1745,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(); * ``` *