Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { FallbackTreatmentsCalculator } from '../';
import type { FallbackTreatmentConfiguration } from '../../../../types/splitio'; // adjust path if needed

describe('FallbackTreatmentsCalculator', () => {

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

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

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

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

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

expect(result).toEqual({
treatment: 'CONTROL',
config: null,
label: 'fallback - label by noFallback',
});
});

test('returns undefined label if no label provided', () => {
const config: FallbackTreatmentConfiguration = {
byFlag: {
'featureB': { treatment: 'TREATMENT_B' },
},
};
const calculator = new FallbackTreatmentsCalculator(config);
const result = calculator.resolve('featureB');

expect(result).toEqual({
treatment: 'TREATMENT_B',
config: undefined,
label: undefined,
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { FallbacksSanitizer } from '../fallbackSanitizer';
import { TreatmentWithConfig } from '../../../../types/splitio';

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

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

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

describe('isValidFlagName', () => {
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(validTreatment)).toEqual(validTreatment);
expect(console.error).not.toHaveBeenCalled();
});

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

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

const result = FallbacksSanitizer.sanitizeByFlag(input);

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

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

const result = FallbacksSanitizer.sanitizeByFlag(input);
expect(result).toEqual({});
expect(console.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(input);
expect(result).toEqual(input);
expect(console.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,68 @@
import { FallbackTreatment } from '../../../../types/splitio';
import { FallbackDiscardReason } from '../constants';


export class FallbacksSanitizer {

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

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

private static isValidTreatment(t?: FallbackTreatment): boolean {
if (!t) {
return false;
}

if (typeof t === 'string') {
if (t.length > 100) {
return false;
}
return FallbacksSanitizer.pattern.test(t);
}

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

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

static sanitizeByFlag(
byFlagFallbacks: Record<string, FallbackTreatment>
): Record<string, FallbackTreatment> {
const sanitizedByFlag: Record<string, FallbackTreatment> = {};

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

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

sanitizedByFlag[flag] = t;
});

return sanitizedByFlag;
}
}
49 changes: 49 additions & 0 deletions src/evaluator/fallbackTreatmentsCalculator/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { FallbackTreatmentConfiguration, FallbackTreatment, IFallbackTreatmentsCalculator} from '../../../types/splitio';

export class FallbackTreatmentsCalculator implements IFallbackTreatmentsCalculator {
private readonly labelPrefix = 'fallback - ';
private readonly control = 'CONTROL';
private readonly fallbacks: FallbackTreatmentConfiguration;

constructor(fallbacks: FallbackTreatmentConfiguration) {
this.fallbacks = fallbacks;
}

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

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

return {
treatment: this.control,
config: null,
label: this.resolveLabel(label),
};
}

private copyWithLabel(fallback: FallbackTreatment, label: string | undefined): FallbackTreatment {
if (typeof fallback === 'string') {
return {
treatment: fallback,
config: null,
label: this.resolveLabel(label),
};
}

return {
treatment: fallback.treatment,
config: fallback.config,
label: this.resolveLabel(label),
};
}

private resolveLabel(label?: string | undefined): string | undefined {
return label ? `${this.labelPrefix}${label}` : undefined;
}

}
24 changes: 24 additions & 0 deletions types/splitio.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,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.
Expand Down Expand Up @@ -1228,6 +1232,26 @@ declare namespace SplitIO {
* User consent status.
*/
type ConsentStatus = 'GRANTED' | 'DECLINED' | 'UNKNOWN';
/**
* Fallback treatment can be either a string (treatment) or an object with treatment, config and label.
*/
type FallbackTreatment = string | {
treatment: string;
config?: string | null;
label?: string | null;
};
/**
* Fallback treatments to be used when the SDK is not ready or the flag is not found.
*/
type FallbackTreatmentConfiguration = {
global?: FallbackTreatment,
byFlag: {
[key: string]: FallbackTreatment
}
}
type IFallbackTreatmentsCalculator = {
resolve(flagName: string, label?: string | undefined): FallbackTreatment;
}
/**
* Logger. Its interface details are not part of the public API. It shouldn't be used directly.
*/
Expand Down