Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
2836af3
Add checkIfServerSide utility function and update client and server-s…
EmilianoSanchez Apr 15, 2025
1eeff81
Add method to retrieve client readiness status synchronously
EmilianoSanchez Oct 1, 2025
bae6f71
Merge branch 'development' into readiness-status
EmilianoSanchez Oct 6, 2025
850b074
Implement FallbackSanitizer and add fallback to config
ZamoraEmmanuel Oct 9, 2025
7371aad
Merge branch 'development' into fme-10504
ZamoraEmmanuel Oct 9, 2025
9163c48
Fix typos
EmilianoSanchez Oct 14, 2025
1724ce8
Updated SDK_READY_FROM_CACHE event to be emitted alongside the SDK_RE…
EmilianoSanchez Oct 14, 2025
154c23f
Merge branch 'readiness-sdk-ready-from-cache' into readiness-status
EmilianoSanchez Oct 14, 2025
cfdd6e6
Merge remote-tracking branch 'origin/readiness-status' into readiness…
EmilianoSanchez Oct 14, 2025
e39f22b
Merge pull request #437 from splitio/fme-10504
ZamoraEmmanuel Oct 14, 2025
1a05cee
[FME-10566] Create FallbackTreatmentsCalculator
ZamoraEmmanuel Oct 16, 2025
381b458
Merge pull request #441 from splitio/fme-10566
ZamoraEmmanuel Oct 16, 2025
53cc6db
feat: add whenReady and whenReadyFromCache methods to replace depreca…
EmilianoSanchez Oct 21, 2025
8b6a8d5
refactor: simplify SDK readiness checks
EmilianoSanchez Oct 22, 2025
96071a4
Merge pull request #438 from splitio/readiness-sdk-ready-from-cache
EmilianoSanchez Oct 22, 2025
2eb71a7
Merge branch 'readiness-baseline' into readiness-status
EmilianoSanchez Oct 22, 2025
1299349
Merge branch 'readiness-status' into readiness-fix-ready-promise
EmilianoSanchez Oct 22, 2025
13eaec3
feat: update whenReadyFromCache to return boolean indicating SDK read…
EmilianoSanchez Oct 22, 2025
e2179d7
Polishing
EmilianoSanchez Oct 22, 2025
8911132
[FME-10567] Add fallbackTreatmentCalculator to client
ZamoraEmmanuel Oct 22, 2025
e49de68
Revert "Add method to retrieve client readiness status synchronously"
EmilianoSanchez Oct 22, 2025
818235b
Revert "Revert "Add method to retrieve client readiness status synchr…
EmilianoSanchez Oct 22, 2025
36ca35e
Polishing
EmilianoSanchez Oct 22, 2025
b8719b2
Polishing
EmilianoSanchez Oct 23, 2025
d9c4cff
Update src/sdkClient/clientInputValidation.ts
ZamoraEmmanuel Oct 23, 2025
c88d787
Merge pull request #444 from splitio/fme-10567-refactor
ZamoraEmmanuel Oct 23, 2025
94814df
Update logs and tests
EmilianoSanchez Oct 23, 2025
c60c4ce
review changes and add fallbacklabel to avoid impression
ZamoraEmmanuel Oct 24, 2025
eeb8073
Prepare release v2.8.0
ZamoraEmmanuel Oct 24, 2025
6cf13c9
remove unnecessary validation
ZamoraEmmanuel Oct 24, 2025
e52c45e
Merge pull request #446 from splitio/review-changes
ZamoraEmmanuel Oct 24, 2025
4230511
Merge branch 'readiness-fix-ready-promise' into readiness-status
EmilianoSanchez Oct 27, 2025
d458b54
Merge pull request #445 from splitio/readiness-fix-ready-promise
EmilianoSanchez Oct 27, 2025
4beb824
Merge pull request #448 from splitio/readiness-baseline
EmilianoSanchez Oct 27, 2025
2b3fac0
Merge branch 'development' into fallback-treatment
EmilianoSanchez Oct 27, 2025
dcbef71
Merge branch 'fallback-treatment' into prepare-release
EmilianoSanchez Oct 27, 2025
ad8d66a
rc
EmilianoSanchez Oct 27, 2025
6092b0c
Merge branch 'development' into readiness-status
EmilianoSanchez Oct 28, 2025
02447ea
Keep __getStatus to avoid breaking change
EmilianoSanchez Oct 28, 2025
f0b1c5d
Merge pull request #439 from splitio/readiness-status
EmilianoSanchez Oct 28, 2025
d58f162
Merge branch 'development' into fallback-treatment
EmilianoSanchez Oct 28, 2025
79df832
Merge branch 'fallback-treatment' into prepare-release
EmilianoSanchez Oct 28, 2025
f4145a9
stable version
EmilianoSanchez Oct 28, 2025
bd5abe3
Merge pull request #447 from splitio/prepare-release
ZamoraEmmanuel Oct 28, 2025
e5f6291
Merge pull request #442 from splitio/fallback-treatment
ZamoraEmmanuel Oct 28, 2025
99b19ae
Merge branch 'development' into refactor_checkIfServerSide_util
EmilianoSanchez Oct 28, 2025
ffa0053
rc
EmilianoSanchez Oct 28, 2025
274fbd6
stable version
EmilianoSanchez Oct 28, 2025
1ded639
Merge pull request #404 from splitio/refactor_checkIfServerSide_util
EmilianoSanchez Oct 28, 2025
16eb793
add fallbackTreatments to shared settings
ZamoraEmmanuel Oct 29, 2025
501174d
Update changelog entry date
EmilianoSanchez Oct 29, 2025
9474cff
refactor: apply fallback sanitization during settings validation
EmilianoSanchez Oct 29, 2025
d3c8665
Update error message
EmilianoSanchez Oct 29, 2025
f2ac5d6
Fix tests - add type validations
ZamoraEmmanuel Oct 29, 2025
26fe9fb
Fix lint
ZamoraEmmanuel Oct 29, 2025
ee50293
refactor: simplify fallback treatment sanitization and type handling
EmilianoSanchez Oct 30, 2025
a629592
ci: pin node version to 22 temporarily in GitHub Actions workflow
EmilianoSanchez Oct 30, 2025
5ae4013
docs: add JSDoc comments for fallback treatment configuration types
EmilianoSanchez Oct 30, 2025
ef7cd27
Merge pull request #451 from splitio/fix-fallbacks-sanitizer
EmilianoSanchez Oct 30, 2025
1f02570
Update changelog entry date
EmilianoSanchez Oct 30, 2025
92b613b
Merge pull request #450 from splitio/fme-10825
EmilianoSanchez Oct 30, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions CHANGES.txt
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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',
});
});
});
Original file line number Diff line number Diff line change
@@ -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: {} });
});
});
});
Original file line number Diff line number Diff line change
@@ -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<string, Treatment | TreatmentWithConfig>
): Record<string, Treatment | TreatmentWithConfig> {
const sanitizedByFlag: Record<string, Treatment | TreatmentWithConfig> = {};

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)
};
}
50 changes: 50 additions & 0 deletions src/evaluator/fallbackTreatmentsCalculator/index.ts
Original file line number Diff line number Diff line change
@@ -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}`,
};
}
}
2 changes: 1 addition & 1 deletion src/logger/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/logger/messages/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.'],
Expand Down
6 changes: 3 additions & 3 deletions src/logger/messages/warn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,16 @@ 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'],
[c.STREAMING_FALLBACK, c.LOG_PREFIX_SYNC_STREAMING + 'Falling back to polling mode. Reason: %s'],
[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.'],
Expand Down
Loading