Skip to content

Commit 9801bb9

Browse files
Merge pull request #449 from splitio/development
Release v2.8.0
2 parents 3b99e8a + 92b613b commit 9801bb9

File tree

43 files changed

+693
-184
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+693
-184
lines changed

.github/workflows/ci.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ jobs:
2424
- name: Set up nodejs
2525
uses: actions/setup-node@v4
2626
with:
27-
node-version: 'lts/*'
27+
# @TODO: rollback to 'lts/*'
28+
node-version: '22'
2829
cache: 'npm'
2930

3031
- name: npm CI

CHANGES.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
2.8.0 (October 30, 2025)
2+
- 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.
3+
- Added `client.getStatus()` method to retrieve the client readiness status properties (`isReady`, `isReadyFromCache`, etc).
4+
- 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.
5+
- Updated the SDK_READY_FROM_CACHE event to be emitted alongside the SDK_READY event if it hasn’t already been emitted.
6+
17
2.7.1 (October 8, 2025)
28
- Bugfix - Update `debug` option to support log levels when `logger` option is used.
39

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@splitsoftware/splitio-commons",
3-
"version": "2.7.1",
3+
"version": "2.8.0",
44
"description": "Split JavaScript SDK common components",
55
"main": "cjs/index.js",
66
"module": "esm/index.js",
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { FallbackTreatmentsCalculator } from '../';
2+
import type { FallbackTreatmentConfiguration } from '../../../../types/splitio';
3+
import { CONTROL } from '../../../utils/constants';
4+
5+
describe('FallbackTreatmentsCalculator' , () => {
6+
test('returns specific fallback if flag exists', () => {
7+
const config: FallbackTreatmentConfiguration = {
8+
byFlag: {
9+
'featureA': { treatment: 'TREATMENT_A', config: '{ value: 1 }' },
10+
},
11+
};
12+
const calculator = new FallbackTreatmentsCalculator(config);
13+
const result = calculator.resolve('featureA', 'label by flag');
14+
15+
expect(result).toEqual({
16+
treatment: 'TREATMENT_A',
17+
config: '{ value: 1 }',
18+
label: 'fallback - label by flag',
19+
});
20+
});
21+
22+
test('returns global fallback if flag is missing and global exists', () => {
23+
const config: FallbackTreatmentConfiguration = {
24+
byFlag: {},
25+
global: { treatment: 'GLOBAL_TREATMENT', config: '{ global: true }' },
26+
};
27+
const calculator = new FallbackTreatmentsCalculator(config);
28+
const result = calculator.resolve('missingFlag', 'label by global');
29+
30+
expect(result).toEqual({
31+
treatment: 'GLOBAL_TREATMENT',
32+
config: '{ global: true }',
33+
label: 'fallback - label by global',
34+
});
35+
});
36+
37+
test('returns control fallback if flag and global are missing', () => {
38+
const config: FallbackTreatmentConfiguration = {
39+
byFlag: {},
40+
};
41+
const calculator = new FallbackTreatmentsCalculator(config);
42+
const result = calculator.resolve('missingFlag', 'label by noFallback');
43+
44+
expect(result).toEqual({
45+
treatment: CONTROL,
46+
config: null,
47+
label: 'label by noFallback',
48+
});
49+
});
50+
});
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { isValidFlagName, isValidTreatment, sanitizeFallbacks } from '../fallbackSanitizer';
2+
import { TreatmentWithConfig } from '../../../../types/splitio';
3+
import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock';
4+
5+
describe('FallbacksSanitizer', () => {
6+
const validTreatment: TreatmentWithConfig = { treatment: 'on', config: '{"color":"blue"}' };
7+
const invalidTreatment: TreatmentWithConfig = { treatment: ' ', config: null };
8+
const fallbackMock = {
9+
global: undefined,
10+
byFlag: {}
11+
};
12+
13+
beforeEach(() => {
14+
loggerMock.mockClear();
15+
});
16+
17+
describe('isValidFlagName', () => {
18+
test('returns true for a valid flag name', () => {
19+
// @ts-expect-private-access
20+
expect(isValidFlagName('my_flag')).toBe(true);
21+
});
22+
23+
test('returns false for a name longer than 100 chars', () => {
24+
const longName = 'a'.repeat(101);
25+
expect(isValidFlagName(longName)).toBe(false);
26+
});
27+
28+
test('returns false if the name contains spaces', () => {
29+
expect(isValidFlagName('invalid flag')).toBe(false);
30+
});
31+
32+
test('returns false if the name contains spaces', () => {
33+
// @ts-ignore
34+
expect(isValidFlagName(true)).toBe(false);
35+
});
36+
});
37+
38+
describe('isValidTreatment', () => {
39+
test('returns true for a valid treatment string', () => {
40+
expect(isValidTreatment(validTreatment)).toBe(true);
41+
});
42+
43+
test('returns false for null or undefined', () => {
44+
expect(isValidTreatment()).toBe(false);
45+
expect(isValidTreatment(undefined)).toBe(false);
46+
});
47+
48+
test('returns false for a treatment longer than 100 chars', () => {
49+
const long = { treatment: 'a'.repeat(101), config: null };
50+
expect(isValidTreatment(long)).toBe(false);
51+
});
52+
53+
test('returns false if treatment does not match regex pattern', () => {
54+
const invalid = { treatment: 'invalid treatment!', config: null };
55+
expect(isValidTreatment(invalid)).toBe(false);
56+
});
57+
});
58+
59+
describe('sanitizeGlobal', () => {
60+
test('returns the treatment if valid', () => {
61+
expect(sanitizeFallbacks(loggerMock, { ...fallbackMock, global: validTreatment })).toEqual({ ...fallbackMock, global: validTreatment });
62+
expect(loggerMock.error).not.toHaveBeenCalled();
63+
});
64+
65+
test('returns undefined and logs error if invalid', () => {
66+
const result = sanitizeFallbacks(loggerMock, { ...fallbackMock, global: invalidTreatment });
67+
expect(result).toEqual(fallbackMock);
68+
expect(loggerMock.error).toHaveBeenCalledWith(
69+
expect.stringContaining('Fallback treatments - Discarded fallback')
70+
);
71+
});
72+
});
73+
74+
describe('sanitizeByFlag', () => {
75+
test('returns a sanitized map with valid entries only', () => {
76+
const input = {
77+
valid_flag: validTreatment,
78+
'invalid flag': validTreatment,
79+
bad_treatment: invalidTreatment,
80+
};
81+
82+
const result = sanitizeFallbacks(loggerMock, {...fallbackMock, byFlag: input});
83+
84+
expect(result).toEqual({ ...fallbackMock, byFlag: { valid_flag: validTreatment } });
85+
expect(loggerMock.error).toHaveBeenCalledTimes(2); // invalid flag + bad_treatment
86+
});
87+
88+
test('returns empty object if all invalid', () => {
89+
const input = {
90+
'invalid flag': invalidTreatment,
91+
};
92+
93+
const result = sanitizeFallbacks(loggerMock, {...fallbackMock, byFlag: input});
94+
expect(result).toEqual(fallbackMock);
95+
expect(loggerMock.error).toHaveBeenCalled();
96+
});
97+
98+
test('returns same object if all valid', () => {
99+
const input = {
100+
...fallbackMock,
101+
byFlag:{
102+
flag_one: validTreatment,
103+
flag_two: { treatment: 'valid_2', config: null },
104+
}
105+
};
106+
107+
const result = sanitizeFallbacks(loggerMock, input);
108+
expect(result).toEqual(input);
109+
expect(loggerMock.error).not.toHaveBeenCalled();
110+
});
111+
});
112+
113+
describe('sanitizeFallbacks', () => {
114+
test('returns undefined and logs error if fallbacks is not an object', () => { // @ts-expect-error
115+
const result = sanitizeFallbacks(loggerMock, 'invalid_fallbacks');
116+
expect(result).toBeUndefined();
117+
expect(loggerMock.error).toHaveBeenCalledWith(
118+
'Fallback treatments - Discarded configuration: it must be an object with optional `global` and `byFlag` properties'
119+
);
120+
});
121+
122+
test('returns undefined and logs error if fallbacks is not an object', () => { // @ts-expect-error
123+
const result = sanitizeFallbacks(loggerMock, true);
124+
expect(result).toBeUndefined();
125+
expect(loggerMock.error).toHaveBeenCalledWith(
126+
'Fallback treatments - Discarded configuration: it must be an object with optional `global` and `byFlag` properties'
127+
);
128+
});
129+
130+
test('sanitizes both global and byFlag fallbacks for empty object', () => { // @ts-expect-error
131+
const result = sanitizeFallbacks(loggerMock, { global: {} });
132+
expect(result).toEqual({ global: undefined, byFlag: {} });
133+
});
134+
});
135+
});
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { FallbackTreatmentConfiguration, Treatment, TreatmentWithConfig } from '../../../../types/splitio';
2+
import { ILogger } from '../../../logger/types';
3+
import { isObject, isString } from '../../../utils/lang';
4+
5+
enum FallbackDiscardReason {
6+
FlagName = 'Invalid flag name (max 100 chars, no spaces)',
7+
Treatment = 'Invalid treatment (max 100 chars and must match pattern)',
8+
}
9+
10+
const TREATMENT_PATTERN = /^[0-9]+[.a-zA-Z0-9_-]*$|^[a-zA-Z]+[a-zA-Z0-9_-]*$/;
11+
12+
export function isValidFlagName(name: string): boolean {
13+
return name.length <= 100 && !name.includes(' ');
14+
}
15+
16+
export function isValidTreatment(t?: Treatment | TreatmentWithConfig): boolean {
17+
const treatment = isObject(t) ? (t as TreatmentWithConfig).treatment : t;
18+
19+
if (!isString(treatment) || treatment.length > 100) {
20+
return false;
21+
}
22+
return TREATMENT_PATTERN.test(treatment);
23+
}
24+
25+
function sanitizeGlobal(logger: ILogger, treatment?: Treatment | TreatmentWithConfig): Treatment | TreatmentWithConfig | undefined {
26+
if (treatment === undefined) return undefined;
27+
if (!isValidTreatment(treatment)) {
28+
logger.error(`Fallback treatments - Discarded fallback: ${FallbackDiscardReason.Treatment}`);
29+
return undefined;
30+
}
31+
return treatment;
32+
}
33+
34+
function sanitizeByFlag(
35+
logger: ILogger,
36+
byFlagFallbacks?: Record<string, Treatment | TreatmentWithConfig>
37+
): Record<string, Treatment | TreatmentWithConfig> {
38+
const sanitizedByFlag: Record<string, Treatment | TreatmentWithConfig> = {};
39+
40+
if (!isObject(byFlagFallbacks)) return sanitizedByFlag;
41+
42+
Object.keys(byFlagFallbacks!).forEach((flag) => {
43+
const t = byFlagFallbacks![flag];
44+
45+
if (!isValidFlagName(flag)) {
46+
logger.error(`Fallback treatments - Discarded flag '${flag}': ${FallbackDiscardReason.FlagName}`);
47+
return;
48+
}
49+
50+
if (!isValidTreatment(t)) {
51+
logger.error(`Fallback treatments - Discarded treatment for flag '${flag}': ${FallbackDiscardReason.Treatment}`);
52+
return;
53+
}
54+
55+
sanitizedByFlag[flag] = t;
56+
});
57+
58+
return sanitizedByFlag;
59+
}
60+
61+
export function sanitizeFallbacks(logger: ILogger, fallbacks: FallbackTreatmentConfiguration): FallbackTreatmentConfiguration | undefined {
62+
if (!isObject(fallbacks)) {
63+
logger.error('Fallback treatments - Discarded configuration: it must be an object with optional `global` and `byFlag` properties');
64+
return;
65+
}
66+
67+
return {
68+
global: sanitizeGlobal(logger, fallbacks.global),
69+
byFlag: sanitizeByFlag(logger, fallbacks.byFlag)
70+
};
71+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { FallbackTreatmentConfiguration, Treatment, TreatmentWithConfig } from '../../../types/splitio';
2+
import { CONTROL } from '../../utils/constants';
3+
import { isString } from '../../utils/lang';
4+
5+
export type IFallbackTreatmentsCalculator = {
6+
resolve(flagName: string, label: string): TreatmentWithConfig & { label: string };
7+
}
8+
9+
export const FALLBACK_PREFIX = 'fallback - ';
10+
11+
export class FallbackTreatmentsCalculator implements IFallbackTreatmentsCalculator {
12+
private readonly fallbacks: FallbackTreatmentConfiguration;
13+
14+
constructor(fallbacks: FallbackTreatmentConfiguration = {}) {
15+
this.fallbacks = fallbacks;
16+
}
17+
18+
resolve(flagName: string, label: string): TreatmentWithConfig & { label: string } {
19+
const treatment = this.fallbacks.byFlag?.[flagName];
20+
if (treatment) {
21+
return this.copyWithLabel(treatment, label);
22+
}
23+
24+
if (this.fallbacks.global) {
25+
return this.copyWithLabel(this.fallbacks.global, label);
26+
}
27+
28+
return {
29+
treatment: CONTROL,
30+
config: null,
31+
label,
32+
};
33+
}
34+
35+
private copyWithLabel(fallback: Treatment | TreatmentWithConfig, label: string): TreatmentWithConfig & { label: string } {
36+
if (isString(fallback)) {
37+
return {
38+
treatment: fallback,
39+
config: null,
40+
label: `${FALLBACK_PREFIX}${label}`,
41+
};
42+
}
43+
44+
return {
45+
treatment: fallback.treatment,
46+
config: fallback.config,
47+
label: `${FALLBACK_PREFIX}${label}`,
48+
};
49+
}
50+
}

src/logger/constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export const SUBMITTERS_PUSH_PAGE_HIDDEN = 125;
6060
export const ENGINE_VALUE_INVALID = 200;
6161
export const ENGINE_VALUE_NO_ATTRIBUTES = 201;
6262
export const CLIENT_NO_LISTENER = 202;
63-
export const CLIENT_NOT_READY = 203;
63+
export const CLIENT_NOT_READY_FROM_CACHE = 203;
6464
export const SYNC_MYSEGMENTS_FETCH_RETRY = 204;
6565
export const SYNC_SPLITS_FETCH_FAILS = 205;
6666
export const STREAMING_PARSING_ERROR_FAILS = 206;

src/logger/messages/info.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export const codesInfo: [number, string][] = codesWarn.concat([
2222
[c.POLLING_SMART_PAUSING, c.LOG_PREFIX_SYNC_POLLING + 'Turning segments data polling %s.'],
2323
[c.POLLING_START, c.LOG_PREFIX_SYNC_POLLING + 'Starting polling'],
2424
[c.POLLING_STOP, c.LOG_PREFIX_SYNC_POLLING + 'Stopping polling'],
25-
[c.SYNC_SPLITS_FETCH_RETRY, c.LOG_PREFIX_SYNC_SPLITS + 'Retrying download of feature flags #%s. Reason: %s'],
25+
[c.SYNC_SPLITS_FETCH_RETRY, c.LOG_PREFIX_SYNC_SPLITS + 'Retrying fetch of feature flags (attempt #%s). Reason: %s'],
2626
[c.SUBMITTERS_PUSH_FULL_QUEUE, c.LOG_PREFIX_SYNC_SUBMITTERS + 'Flushing full %s queue and resetting timer.'],
2727
[c.SUBMITTERS_PUSH, c.LOG_PREFIX_SYNC_SUBMITTERS + 'Pushing %s.'],
2828
[c.SUBMITTERS_PUSH_PAGE_HIDDEN, c.LOG_PREFIX_SYNC_SUBMITTERS + 'Flushing %s because page became hidden.'],

0 commit comments

Comments
 (0)