Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
850b074
Implement FallbackSanitizer and add fallback to config
ZamoraEmmanuel Oct 9, 2025
7371aad
Merge branch 'development' into fme-10504
ZamoraEmmanuel Oct 9, 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
8911132
[FME-10567] Add fallbackTreatmentCalculator to client
ZamoraEmmanuel Oct 22, 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
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
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
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
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
1 change: 1 addition & 0 deletions CHANGES.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
2.8.0 (October 28, 2025)
- Added new configuration for Fallback Treatments, which allows setting a treatment value and optional config to be returned in place of "control", either globally or by flag. Read more in our docs.
- Added `client.getStatus()` method to retrieve the client readiness status properties (`isReady`, `isReadyFromCache`, etc).
- Added `client.whenReady()` and `client.whenReadyFromCache()` methods to replace the deprecated `client.ready()` method, which has an issue causing the returned promise to hang when using async/await syntax if it was rejected.
- Updated the SDK_READY_FROM_CACHE event to be emitted alongside the SDK_READY event if it hasn’t already been emitted.
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,134 @@
import { FallbackTreatmentsCalculator } from '../';
import type { FallbackTreatmentConfiguration } from '../../../../types/splitio';
import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock';
import { CONTROL } from '../../../utils/constants';

describe('FallbackTreatmentsCalculator' , () => {
const longName = 'a'.repeat(101);

test('logs an error if flag name is invalid - by Flag', () => {
let config: FallbackTreatmentConfiguration = {
byFlag: {
'feature A': { treatment: 'TREATMENT_A', config: '{ value: 1 }' },
},
};
new FallbackTreatmentsCalculator(loggerMock, config);
expect(loggerMock.error.mock.calls[0][0]).toBe(
'Fallback treatments - Discarded flag \'feature A\': Invalid flag name (max 100 chars, no spaces)'
);
config = {
byFlag: {
[longName]: { treatment: 'TREATMENT_A', config: '{ value: 1 }' },
},
};
new FallbackTreatmentsCalculator(loggerMock, config);
expect(loggerMock.error.mock.calls[1][0]).toBe(
`Fallback treatments - Discarded flag '${longName}': Invalid flag name (max 100 chars, no spaces)`
);

config = {
byFlag: {
'featureB': { treatment: longName, config: '{ value: 1 }' },
},
};
new FallbackTreatmentsCalculator(loggerMock, config);
expect(loggerMock.error.mock.calls[2][0]).toBe(
'Fallback treatments - Discarded treatment for flag \'featureB\': Invalid treatment (max 100 chars and must match pattern)'
);

config = {
byFlag: {
// @ts-ignore
'featureC': { config: '{ global: true }' },
},
};
new FallbackTreatmentsCalculator(loggerMock, config);
expect(loggerMock.error.mock.calls[3][0]).toBe(
'Fallback treatments - Discarded treatment for flag \'featureC\': Invalid treatment (max 100 chars and must match pattern)'
);

config = {
byFlag: {
// @ts-ignore
'featureC': { treatment: 'invalid treatment!', config: '{ global: true }' },
},
};
new FallbackTreatmentsCalculator(loggerMock, config);
expect(loggerMock.error.mock.calls[4][0]).toBe(
'Fallback treatments - Discarded treatment for flag \'featureC\': Invalid treatment (max 100 chars and must match pattern)'
);
});

test('logs an error if flag name is invalid - global', () => {
let config: FallbackTreatmentConfiguration = {
global: { treatment: longName, config: '{ value: 1 }' },
};
new FallbackTreatmentsCalculator(loggerMock, config);
expect(loggerMock.error.mock.calls[2][0]).toBe(
'Fallback treatments - Discarded treatment for flag \'featureB\': Invalid treatment (max 100 chars and must match pattern)'
);

config = {
// @ts-ignore
global: { config: '{ global: true }' },
};
new FallbackTreatmentsCalculator(loggerMock, config);
expect(loggerMock.error.mock.calls[3][0]).toBe(
'Fallback treatments - Discarded treatment for flag \'featureC\': Invalid treatment (max 100 chars and must match pattern)'
);

config = {
// @ts-ignore
global: { treatment: 'invalid treatment!', config: '{ global: true }' },
};
new FallbackTreatmentsCalculator(loggerMock, config);
expect(loggerMock.error.mock.calls[4][0]).toBe(
'Fallback treatments - Discarded treatment for flag \'featureC\': Invalid treatment (max 100 chars and must match pattern)'
);
});

test('returns specific fallback if flag exists', () => {
const config: FallbackTreatmentConfiguration = {
byFlag: {
'featureA': { treatment: 'TREATMENT_A', config: '{ value: 1 }' },
},
};
const calculator = new FallbackTreatmentsCalculator(loggerMock, config);
const result = calculator.resolve('featureA', 'label by flag');

expect(result).toEqual({
treatment: 'TREATMENT_A',
config: '{ value: 1 }',
label: 'fallback - label by flag',
});
});

test('returns global fallback if flag is missing and global exists', () => {
const config: FallbackTreatmentConfiguration = {
byFlag: {},
global: { treatment: 'GLOBAL_TREATMENT', config: '{ global: true }' },
};
const calculator = new FallbackTreatmentsCalculator(loggerMock, config);
const result = calculator.resolve('missingFlag', 'label by global');

expect(result).toEqual({
treatment: 'GLOBAL_TREATMENT',
config: '{ global: true }',
label: 'fallback - label by global',
});
});

test('returns control fallback if flag and global are missing', () => {
const config: FallbackTreatmentConfiguration = {
byFlag: {},
};
const calculator = new FallbackTreatmentsCalculator(loggerMock, config);
const result = calculator.resolve('missingFlag', 'label by noFallback');

expect(result).toEqual({
treatment: CONTROL,
config: null,
label: 'label by noFallback',
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { FallbacksSanitizer } from '../fallbackSanitizer';
import { TreatmentWithConfig } from '../../../../types/splitio';
import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock';

describe('FallbacksSanitizer', () => {
const validTreatment: TreatmentWithConfig = { treatment: 'on', config: '{"color":"blue"}' };
const invalidTreatment: TreatmentWithConfig = { treatment: ' ', config: null };

beforeEach(() => {
jest.spyOn(console, 'error').mockImplementation(() => {});
});

afterEach(() => {
(loggerMock.error as jest.Mock).mockRestore();
});

describe('isValidFlagName', () => {
test('returns true for a valid flag name', () => {
// @ts-expect-private-access
expect((FallbacksSanitizer as any).isValidFlagName('my_flag')).toBe(true);
});

test('returns false for a name longer than 100 chars', () => {
const longName = 'a'.repeat(101);
expect((FallbacksSanitizer as any).isValidFlagName(longName)).toBe(false);
});

test('returns false if the name contains spaces', () => {
expect((FallbacksSanitizer as any).isValidFlagName('invalid flag')).toBe(false);
});
});

describe('isValidTreatment', () => {
test('returns true for a valid treatment string', () => {
expect((FallbacksSanitizer as any).isValidTreatment(validTreatment)).toBe(true);
});

test('returns false for null or undefined', () => {
expect((FallbacksSanitizer as any).isValidTreatment(null)).toBe(false);
expect((FallbacksSanitizer as any).isValidTreatment(undefined)).toBe(false);
});

test('returns false for a treatment longer than 100 chars', () => {
const long = { treatment: 'a'.repeat(101) };
expect((FallbacksSanitizer as any).isValidTreatment(long)).toBe(false);
});

test('returns false if treatment does not match regex pattern', () => {
const invalid = { treatment: 'invalid treatment!' };
expect((FallbacksSanitizer as any).isValidTreatment(invalid)).toBe(false);
});
});

describe('sanitizeGlobal', () => {
test('returns the treatment if valid', () => {
expect(FallbacksSanitizer.sanitizeGlobal(loggerMock, validTreatment)).toEqual(validTreatment);
expect(loggerMock.error).not.toHaveBeenCalled();
});

test('returns undefined and logs error if invalid', () => {
const result = FallbacksSanitizer.sanitizeGlobal(loggerMock, invalidTreatment);
expect(result).toBeUndefined();
expect(loggerMock.error).toHaveBeenCalledWith(
expect.stringContaining('Fallback treatments - Discarded fallback')
);
});
});

describe('sanitizeByFlag', () => {
test('returns a sanitized map with valid entries only', () => {
const input = {
valid_flag: validTreatment,
'invalid flag': validTreatment,
bad_treatment: invalidTreatment,
};

const result = FallbacksSanitizer.sanitizeByFlag(loggerMock, input);

expect(result).toEqual({ valid_flag: validTreatment });
expect(loggerMock.error).toHaveBeenCalledTimes(2); // invalid flag + bad_treatment
});

test('returns empty object if all invalid', () => {
const input = {
'invalid flag': invalidTreatment,
};

const result = FallbacksSanitizer.sanitizeByFlag(loggerMock, input);
expect(result).toEqual({});
expect(loggerMock.error).toHaveBeenCalled();
});

test('returns same object if all valid', () => {
const input = {
flag_one: validTreatment,
flag_two: { treatment: 'valid_2', config: null },
};

const result = FallbacksSanitizer.sanitizeByFlag(loggerMock, input);
expect(result).toEqual(input);
expect(loggerMock.error).not.toHaveBeenCalled();
});
});
});
4 changes: 4 additions & 0 deletions src/evaluator/fallbackTreatmentsCalculator/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export enum FallbackDiscardReason {
FlagName = 'Invalid flag name (max 100 chars, no spaces)',
Treatment = 'Invalid treatment (max 100 chars and must match pattern)',
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { Treatment, TreatmentWithConfig } from '../../../../types/splitio';
import { ILogger } from '../../../logger/types';
import { isObject, isString } from '../../../utils/lang';
import { FallbackDiscardReason } from '../constants';


export class FallbacksSanitizer {

private static readonly pattern = /^[0-9]+[.a-zA-Z0-9_-]*$|^[a-zA-Z]+[a-zA-Z0-9_-]*$/;

private static isValidFlagName(name: string): boolean {
return name.length <= 100 && !name.includes(' ');
}

private static isValidTreatment(t?: Treatment | TreatmentWithConfig): boolean {
const treatment = isObject(t) ? (t as TreatmentWithConfig).treatment : t;

if (!isString(treatment) || treatment.length > 100) {
return false;
}
return FallbacksSanitizer.pattern.test(treatment);
}

static sanitizeGlobal(logger: ILogger, treatment?: Treatment | TreatmentWithConfig): Treatment | TreatmentWithConfig | undefined {
if (!this.isValidTreatment(treatment)) {
logger.error(
`Fallback treatments - Discarded fallback: ${FallbackDiscardReason.Treatment}`
);
return undefined;
}
return treatment;
}

static sanitizeByFlag(
logger: ILogger,
byFlagFallbacks: Record<string, Treatment | TreatmentWithConfig>
): Record<string, Treatment | TreatmentWithConfig> {
const sanitizedByFlag: Record<string, Treatment | TreatmentWithConfig> = {};

const entries = Object.keys(byFlagFallbacks);
entries.forEach((flag) => {
const t = byFlagFallbacks[flag];
if (!this.isValidFlagName(flag)) {
logger.error(
`Fallback treatments - Discarded flag '${flag}': ${FallbackDiscardReason.FlagName}`
);
return;
}

if (!this.isValidTreatment(t)) {
logger.error(
`Fallback treatments - Discarded treatment for flag '${flag}': ${FallbackDiscardReason.Treatment}`
);
return;
}

sanitizedByFlag[flag] = t;
});

return sanitizedByFlag;
}
}
57 changes: 57 additions & 0 deletions src/evaluator/fallbackTreatmentsCalculator/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { FallbackTreatmentConfiguration, Treatment, TreatmentWithConfig } from '../../../types/splitio';
import { FallbacksSanitizer } from './fallbackSanitizer';
import { CONTROL } from '../../utils/constants';
import { isString } from '../../utils/lang';
import { ILogger } from '../../logger/types';

export type IFallbackTreatmentsCalculator = {
resolve(flagName: string, label: string): TreatmentWithConfig & { label: string };
}

export const FALLBACK_PREFIX = 'fallback - ';

export class FallbackTreatmentsCalculator implements IFallbackTreatmentsCalculator {
private readonly fallbacks: FallbackTreatmentConfiguration;

constructor(logger: ILogger, fallbacks?: FallbackTreatmentConfiguration) {
const sanitizedGlobal = fallbacks?.global ? FallbacksSanitizer.sanitizeGlobal(logger, fallbacks.global) : undefined;
const sanitizedByFlag = fallbacks?.byFlag ? FallbacksSanitizer.sanitizeByFlag(logger, fallbacks.byFlag) : {};
this.fallbacks = {
global: sanitizedGlobal,
byFlag: sanitizedByFlag
};
}

resolve(flagName: string, label: string): TreatmentWithConfig & { label: string } {
const treatment = this.fallbacks.byFlag?.[flagName];
if (treatment) {
return this.copyWithLabel(treatment, label);
}

if (this.fallbacks.global) {
return this.copyWithLabel(this.fallbacks.global, label);
}

return {
treatment: CONTROL,
config: null,
label,
};
}

private copyWithLabel(fallback: Treatment | TreatmentWithConfig, label: string): TreatmentWithConfig & { label: string } {
if (isString(fallback)) {
return {
treatment: fallback,
config: null,
label: `${FALLBACK_PREFIX}${label}`,
};
}

return {
treatment: fallback.treatment,
config: fallback.config,
label: `${FALLBACK_PREFIX}${label}`,
};
}
}
Loading