Skip to content

Commit 043e96c

Browse files
Merge branch 'refactor_checkIfServerSide_util' into handle-segmentchanges-timeout
2 parents 8f2fb26 + ffa0053 commit 043e96c

File tree

38 files changed

+725
-179
lines changed

38 files changed

+725
-179
lines changed

CHANGES.txt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1-
2.8.0 (October XX, 2025)
1+
2.9.0 (October XX, 2025)
22
- Updated the SDK’s initial synchronization in Node.js (server-side) to use the `startup.requestTimeoutBeforeReady` and `startup.retriesOnFailureBeforeReady` options to control the timeout and retry behavior of segment requests.
33

4+
2.8.0 (October 28, 2025)
5+
- 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.
6+
- Added `client.getStatus()` method to retrieve the client readiness status properties (`isReady`, `isReadyFromCache`, etc).
7+
- 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.
8+
- Updated the SDK_READY_FROM_CACHE event to be emitted alongside the SDK_READY event if it hasn’t already been emitted.
9+
410
2.7.1 (October 8, 2025)
511
- Bugfix - Update `debug` option to support log levels when `logger` option is used.
612

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

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/warn.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ export const codesWarn: [number, string][] = codesError.concat([
1414
[c.SUBMITTERS_PUSH_FAILS, c.LOG_PREFIX_SYNC_SUBMITTERS + 'Dropping %s after retry. Reason: %s.'],
1515
[c.SUBMITTERS_PUSH_RETRY, c.LOG_PREFIX_SYNC_SUBMITTERS + 'Failed to push %s, keeping data to retry on next iteration. Reason: %s.'],
1616
// client status
17-
[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.'],
18-
[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.'],
17+
[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.'],
18+
[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.'],
1919
// input validation
2020
[c.WARN_SETTING_NULL, '%s: Property "%s" is of invalid type. Setting value to null.'],
2121
[c.WARN_TRIMMING_PROPERTIES, '%s: more than 300 properties were provided. Some of them will be trimmed when processed.'],

0 commit comments

Comments
 (0)