Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion CHANGES.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
3.2.0 (August 28, 2026)
3.2.0 (September 18, 2026)
- Added support for AI configs.
- Removed Configs SDK-related types and modules and moved them to the Configs SDK repository.

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": "3.1.1-rc.4",
"version": "3.1.1-rc.7",
"description": "Split JavaScript SDK common components",
"main": "cjs/index.js",
"module": "esm/index.js",
Expand Down
4 changes: 2 additions & 2 deletions src/__tests__/testUtils/jwt.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { IJwtCredentialV3 } from '../../sync/streaming/AuthClient/types';
import { IJwtCredential } from '../../sync/streaming/AuthClient/types';

function toBase64Url(str: string) {
return Buffer.from(str).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}

export function makeJwtCredential(expInSeconds = 3600): IJwtCredentialV3 {
export function makeJwtCredential(expInSeconds = 3600): IJwtCredential {
const now = Math.floor(Date.now() / 1000);
const header = toBase64Url(JSON.stringify({ alg: 'HS256' }));
const decodedToken = { iat: now, exp: now + expInSeconds, 'x-ably-capability': '{"ch":["subscribe"]}' };
Expand Down
4 changes: 2 additions & 2 deletions src/presets/serverSide.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { serviceApiFactory } from '../services/serviceApi';
import { splitApiFactory } from '../services/splitApi';
import { syncManagerOnlineFactory } from '../sync/syncManagerOnline';
import { pushManagerFactory } from '../sync/streaming/pushManager';
import { pollingManagerSSFactory } from '../sync/polling/pollingManagerSS';
Expand All @@ -11,7 +11,7 @@ const syncManagerOnlineSSFactory = syncManagerOnlineFactory(pollingManagerSSFact

export const serverSideModules = {
storageFactory: InMemoryStorageFactory,
serviceApiFactory,
serviceApiFactory: splitApiFactory,
syncManagerFactory: syncManagerOnlineSSFactory,
sdkManagerFactory,
sdkClientMethodFactory,
Expand Down
3 changes: 2 additions & 1 deletion src/sdkClient/sdkLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,10 @@ export function sdkLifecycleFactory(params: ISdkFactoryContext, isSharedClient?:

// Stop background jobs
syncManager && syncManager.stop();
serviceApi && serviceApi.stop();

return __flush().then(() => {
// Stop the service API (the auth provider backoff retries specifically) after the final flush, so it can still authenticate using the still-valid cached credential
serviceApi && serviceApi.stop();
// Cleanup storage
return storage.destroy();
});
Expand Down
6 changes: 5 additions & 1 deletion src/services/__tests__/secureSplitHttpClient.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { secureSplitHttpClientFactory } from '../secureSplitHttpClient';
import { splitHttpClientFactory } from '../splitHttpClient';
import { authProviderFactory } from '../authProvider';
import { Backoff } from '../../utils/Backoff';
import { makeJwtCredential } from '../../__tests__/testUtils/jwt';

Expand Down Expand Up @@ -27,7 +29,9 @@ function createSecureSplitHttpClient(configsHandler: (callCount: number) => any)
configsCallCount++;
return configsHandler(configsCallCount);
});
const client = secureSplitHttpClientFactory(mockSettings, { getFetch: () => fetchImpl, getOptions: () => undefined }, mockTelemetryTracker);
const splitHttpClient = splitHttpClientFactory(mockSettings, { getFetch: () => fetchImpl, getOptions: () => undefined });
const authProvider = authProviderFactory(mockSettings, splitHttpClient, mockTelemetryTracker);
const client = secureSplitHttpClientFactory(splitHttpClient, authProvider);
return { client, fetchImpl };
}

Expand Down
4 changes: 2 additions & 2 deletions src/services/__tests__/splitApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ describe('serviceApi', () => {

test.each([settingsServiceApi, settingsWithRuntime, settingsWithSets])('performs requests with expected headers', (settings) => {

const fetchMock = jest.fn(() => Promise.resolve({ ok: true }));
const fetchMock = jest.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) }));
const serviceApi = splitApiFactory(settings, { getFetch: () => fetchMock }, telemetryTrackerMock);

serviceApi.fetchAuth(['key1', 'key2']);
Expand Down Expand Up @@ -92,7 +92,7 @@ describe('serviceApi', () => {
const serviceApi = splitApiFactory(settingsServiceApi, { getFetch: () => undefined }, telemetryTrackerMock);

// Invoking any Service method, returns a rejected promise with Split error
serviceApi.fetchAuth().catch(error => {
serviceApi.fetchConfigs().catch(error => {
expect(error).toBeInstanceOf(Error);
expect(error.message).toBe('Global fetch API is not available.');
done();
Expand Down
32 changes: 19 additions & 13 deletions src/services/authProvider.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ISplitHttpClient, NetworkError } from './types';
import { IJwtCredentialV3 } from '../sync/streaming/AuthClient/types';
import { authenticateFactory } from '../sync/streaming/AuthClient';
import { IJwtCredential } from '../sync/streaming/AuthClient/types';
import { fetchAuthFactory } from '../sync/streaming/AuthClient';
import { Backoff } from '../utils/Backoff';
import { LOG_PREFIX_SYNC_AUTH } from '../logger/constants';
import { ISettings } from '../types';
Expand All @@ -9,12 +9,20 @@ import { ITelemetryTracker } from '../trackers/types';

const SKEW_SECONDS = 30;

function isExpired(credential: IJwtCredentialV3): boolean {
function isExpired(credential: IJwtCredential): boolean {
return Date.now() / 1000 + SKEW_SECONDS >= credential.decodedToken.exp;
}

export interface IAuthProvider {
credential(): Promise<IJwtCredentialV3>;
/**
* Returns the cached credential, or fetches a new one if there isn't one cached or it's expired,
* retrying with backoff on recoverable errors. Used by `secureSplitHttpClient` and `serviceApi.fetchAuth`.
*/
credential(): Promise<IJwtCredential>;
/**
* Invalidates/clears the cached credential. Used by `secureSplitHttpClient` in the special case of 401 error,
* and by `serviceApi.fetchAuth` to force a credential/token refresh.
*/
invalidate(): void;
stop(): void;
}
Expand All @@ -27,20 +35,18 @@ export function authProviderFactory(settings: ISettings, splitHttpClient: ISplit

const { urls, log } = settings;

function fetchAuth() {
const fetchAuth = fetchAuthFactory(() => {
let url = `${urls.auth}/api/v3/auth?capabilities=config,aiconfig`;
return splitHttpClient(url, undefined, telemetryTracker.trackHttp(TOKEN), false, true);
}

const authenticate = authenticateFactory(fetchAuth);
});
const backoff = new Backoff(fetchCredential);

let cachedCredential: IJwtCredentialV3 | undefined;
let inFlightPromise: Promise<IJwtCredentialV3> | undefined;
let cachedCredential: IJwtCredential | undefined;
let inFlightPromise: Promise<IJwtCredential> | undefined;
let stopped = false;

function fetchCredential(): Promise<IJwtCredentialV3> {
return authenticate().then((credential: IJwtCredentialV3) => {
function fetchCredential(): Promise<IJwtCredential> {
return fetchAuth().then((credential: IJwtCredential) => {
log.info(LOG_PREFIX_SYNC_AUTH + 'credential fetched successfully');
cachedCredential = credential;
inFlightPromise = undefined;
Expand All @@ -62,7 +68,7 @@ export function authProviderFactory(settings: ISettings, splitHttpClient: ISplit
}

return {
credential(): Promise<IJwtCredentialV3> {
credential(): Promise<IJwtCredential> {
if (cachedCredential && !isExpired(cachedCredential)) {
return Promise.resolve(cachedCredential);
}
Expand Down
28 changes: 10 additions & 18 deletions src/services/secureSplitHttpClient.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,28 @@
import { IRequestOptions, IResponse, ISecureSplitHttpClient, NetworkError } from './types';
import { ISettings } from '../types';
import { IPlatform } from '../sdkFactory/types';
import { splitHttpClientFactory } from './splitHttpClient';
import { authProviderFactory } from './authProvider';
import { ITelemetryTracker } from '../trackers/types';
import { IRequestOptions, IResponse, ISecureSplitHttpClient, ISplitHttpClient, NetworkError } from './types';
import { IAuthProvider } from './authProvider';

/**
* Factory of Secure HTTP client, which authenticates requests using a JWT token.
* On 401 responses, invalidates the cached credential and retries once with a fresh token.
*
* @param settings - SDK settings
* @param platform - object containing environment-specific dependencies
* @param fetchAuth - function to fetch auth credentials from the /v2/auth endpoint
* @param splitHttpClient - `splitHttpClientFactory` to use for making requests
* @param authProvider - `authProviderFactory` to use for authentication
*/
export function secureSplitHttpClientFactory(settings: ISettings, platform: Pick<IPlatform, 'getOptions' | 'getFetch'>, telemetryTracker: ITelemetryTracker): ISecureSplitHttpClient {
export function secureSplitHttpClientFactory(splitHttpClient: ISplitHttpClient, authProvider: IAuthProvider): ISecureSplitHttpClient {

const splitHttpClient = splitHttpClientFactory(settings, platform);
const authProvider = authProviderFactory(settings, splitHttpClient, telemetryTracker);

function makeRequest(url: string, options: IRequestOptions | undefined, latencyTracker: ((error?: NetworkError) => void) | undefined, logErrorsAsInfo: boolean | undefined, token: string): Promise<IResponse> {
return splitHttpClient(url, { ...options, headers: { ...options?.headers, Authorization: `Bearer ${token}` } }, latencyTracker, logErrorsAsInfo, true);
function makeRequest(url: string, options: IRequestOptions | undefined, latencyTracker?: (error?: NetworkError) => void, logErrorsAsInfo?: boolean, newVersionHeader?: boolean, token?: string): Promise<IResponse> {
return splitHttpClient(url, token ? { ...options, headers: { ...options?.headers, Authorization: `Bearer ${token}` } } : options, latencyTracker, logErrorsAsInfo, newVersionHeader);
}

const httpClient = function (url: string, options?: IRequestOptions, latencyTracker?: (error?: NetworkError) => void, logErrorsAsInfo?: boolean): Promise<IResponse> {
const httpClient = function (url: string, options?: IRequestOptions, latencyTracker?: (error?: NetworkError) => void, logErrorsAsInfo?: boolean, newVersionHeader = true, useJwt = true): Promise<IResponse> {
return authProvider.credential().then(credential => {
return makeRequest(url, options, latencyTracker, logErrorsAsInfo, credential.token)
return makeRequest(url, options, latencyTracker, logErrorsAsInfo, newVersionHeader, useJwt ? credential.token : undefined)
.catch((error: NetworkError) => {
if (error.statusCode === 401) {
// retry once for 401, in case the token has just expired
authProvider.invalidate();
return authProvider.credential().then(newCredential => {
return makeRequest(url, options, latencyTracker, logErrorsAsInfo, newCredential.token);
return makeRequest(url, options, latencyTracker, logErrorsAsInfo, newVersionHeader, useJwt ? newCredential.token : undefined);
});
}
throw error;
Expand Down
51 changes: 27 additions & 24 deletions src/services/serviceApi.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,52 @@
import { IPlatform } from '../sdkFactory/types';
import { ISettings } from '../types';
import { splitHttpClientFactory } from './splitHttpClient';
import { ISecureSplitHttpClient, IServiceApi } from './types';
import { IServiceApi } from './types';
import { objectAssign } from '../utils/lang/objectAssign';
import { ITelemetryTracker } from '../trackers/types';
import { SPLITS, IMPRESSIONS, IMPRESSIONS_COUNT, EVENTS, TELEMETRY, TOKEN, SEGMENT, MEMBERSHIPS } from '../utils/constants';
import { SPLITS, IMPRESSIONS, IMPRESSIONS_COUNT, EVENTS, TELEMETRY, SEGMENT, MEMBERSHIPS } from '../utils/constants';
import { ERROR_TOO_MANY_SETS } from '../logger/constants';
import { authProviderFactory } from './authProvider';
import { secureSplitHttpClientFactory } from './secureSplitHttpClient';

const noCacheHeaderOptions = { headers: { 'Cache-Control': 'no-cache' } };

function userKeyToQueryParam(userKey: string) {
return 'users=' + encodeURIComponent(userKey); // no need to check availability of `encodeURIComponent`, since it is a global highly supported.
}

/**
* Factory of ServiceApi objects, which group the collection of HTTP endpoints used by the SDKs
*
* @param settings - validated settings object
* @param platform - object containing environment-specific dependencies
* @param telemetryTracker - telemetry tracker
* @param secureSplitHttpClientFactory - factory of SecureSplitHttpClient objects
*/
export function serviceApiFactory(
settings: ISettings,
platform: Pick<IPlatform, 'getOptions' | 'getFetch'>,
telemetryTracker: ITelemetryTracker,
secureSplitHttpClientFactory?: (settings: ISettings, platform: Pick<IPlatform, 'getOptions' | 'getFetch'>, telemetryTracker: ITelemetryTracker) => ISecureSplitHttpClient,
): IServiceApi {

const urls = settings.urls;
const filterQueryString = settings.sync.__splitFiltersValidation && settings.sync.__splitFiltersValidation.queryString;
const SplitSDKImpressionsMode = settings.sync.impressionsMode;

const splitHttpClient = splitHttpClientFactory(settings, platform);
const secureSplitHttpClient = secureSplitHttpClientFactory!(settings, platform, telemetryTracker);

// Shared authProvider so that the SSE authentication (via `fetchAuth`,
// used by `pushManager`) and every authenticated HTTP request to the Configs
// API reuse the same cached credential instead of each fetching their own.
const authProvider = authProviderFactory(settings, splitHttpClient, telemetryTracker);
const secureSplitHttpClient = secureSplitHttpClientFactory(splitHttpClient, authProvider);

let initialAuth = true;

return {
fetchAuth() {
// Guard condition to avoid invalidating the cached credential on the first pushManager authentication
if (initialAuth) initialAuth = false;
else authProvider.invalidate();

return authProvider.credential();
},

// @TODO throw errors if health check requests fail, to log them in the Synchronizer
getSdkAPIHealthCheck() {
const url = `${urls.sdk}/api/version`;
Expand All @@ -46,15 +58,6 @@ export function serviceApiFactory(
return splitHttpClient(url).then(() => true).catch(() => false);
},

fetchAuth(userMatchingKeys?: string[]) {
let url = `${urls.auth}/api/v2/auth?s=${settings.sync.flagSpecVersion}`;
if (userMatchingKeys) { // `userMatchingKeys` is undefined in server-side
const queryParams = userMatchingKeys.map(userKeyToQueryParam).join('&');
if (queryParams) url += '&' + queryParams;
}
return splitHttpClient(url, undefined, telemetryTracker.trackHttp(TOKEN));
},

fetchSplitChanges(since: number, noCache?: boolean, till?: number, rbSince?: number) {
const url = `${urls.sdk}/api/splitChanges?s=${settings.sync.flagSpecVersion}&since=${since}${rbSince ? '&rbSince=' + rbSince : ''}${filterQueryString || ''}${till ? '&till=' + till : ''}`;
return splitHttpClient(url, noCache ? noCacheHeaderOptions : undefined, telemetryTracker.trackHttp(SPLITS))
Expand Down Expand Up @@ -99,7 +102,7 @@ export function serviceApiFactory(
*/
postEventsBulk(body: string, headers?: Record<string, string>) {
const url = `${urls.events}/api/events/bulk`;
return splitHttpClient(url, { method: 'POST', body, headers }, telemetryTracker.trackHttp(EVENTS));
return secureSplitHttpClient(url, { method: 'POST', body, headers }, telemetryTracker.trackHttp(EVENTS), false, false, false);
},

/**
Expand All @@ -110,10 +113,10 @@ export function serviceApiFactory(
*/
postTestImpressionsBulk(body: string, headers?: Record<string, string>) {
const url = `${urls.events}/api/testImpressions/bulk`;
return splitHttpClient(url, {
return secureSplitHttpClient(url, {
// Adding extra headers to send impressions in OPTIMIZED or DEBUG modes.
method: 'POST', body, headers: objectAssign({ SplitSDKImpressionsMode }, headers)
}, telemetryTracker.trackHttp(IMPRESSIONS));
}, telemetryTracker.trackHttp(IMPRESSIONS), false, false, false);
},

/**
Expand All @@ -124,7 +127,7 @@ export function serviceApiFactory(
*/
postTestImpressionsCount(body: string, headers?: Record<string, string>) {
const url = `${urls.events}/api/testImpressions/count`;
return splitHttpClient(url, { method: 'POST', body, headers }, telemetryTracker.trackHttp(IMPRESSIONS_COUNT));
return secureSplitHttpClient(url, { method: 'POST', body, headers }, telemetryTracker.trackHttp(IMPRESSIONS_COUNT), false, false, false);
},

/**
Expand All @@ -135,7 +138,7 @@ export function serviceApiFactory(
*/
postUniqueKeysBulkCs(body: string, headers?: Record<string, string>) {
const url = `${urls.telemetry}/api/v1/keys/cs`;
return splitHttpClient(url, { method: 'POST', body, headers }, telemetryTracker.trackHttp(TELEMETRY));
return secureSplitHttpClient(url, { method: 'POST', body, headers }, telemetryTracker.trackHttp(TELEMETRY), false, false, false);
},

/**
Expand All @@ -146,7 +149,7 @@ export function serviceApiFactory(
*/
postUniqueKeysBulkSs(body: string, headers?: Record<string, string>) {
const url = `${urls.telemetry}/api/v1/keys/ss`;
return splitHttpClient(url, { method: 'POST', body, headers }, telemetryTracker.trackHttp(TELEMETRY));
return secureSplitHttpClient(url, { method: 'POST', body, headers }, telemetryTracker.trackHttp(TELEMETRY), false, false, false);
},

postMetricsConfig(body: string, headers?: Record<string, string>) {
Expand Down
19 changes: 10 additions & 9 deletions src/services/splitApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { objectAssign } from '../utils/lang/objectAssign';
import { ITelemetryTracker } from '../trackers/types';
import { SPLITS, IMPRESSIONS, IMPRESSIONS_COUNT, EVENTS, TELEMETRY, TOKEN, SEGMENT, MEMBERSHIPS } from '../utils/constants';
import { ERROR_TOO_MANY_SETS } from '../logger/constants';
import { fetchAuthFactory } from '../sync/streaming/AuthClient';

const noCacheHeaderOptions = { headers: { 'Cache-Control': 'no-cache' } };

Expand Down Expand Up @@ -33,6 +34,15 @@ export function splitApiFactory(
const splitHttpClient = splitHttpClientFactory(settings, platform);

return {
fetchAuth: fetchAuthFactory((userMatchingKeys?: string[]) => {
let url = `${urls.auth}/v2/auth?s=${settings.sync.flagSpecVersion}`;
if (userMatchingKeys) { // `userMatchingKeys` is undefined in server-side
const queryParams = userMatchingKeys.map(userKeyToQueryParam).join('&');
if (queryParams) url += '&' + queryParams;
}
return splitHttpClient(url, undefined, telemetryTracker.trackHttp(TOKEN));
}),

// @TODO throw errors if health check requests fail, to log them in the Synchronizer
getSdkAPIHealthCheck() {
const url = `${urls.sdk}/version`;
Expand All @@ -44,15 +54,6 @@ export function splitApiFactory(
return splitHttpClient(url).then(() => true).catch(() => false);
},

fetchAuth(userMatchingKeys?: string[]) {
let url = `${urls.auth}/v2/auth?s=${settings.sync.flagSpecVersion}`;
if (userMatchingKeys) { // `userMatchingKeys` is undefined in server-side
const queryParams = userMatchingKeys.map(userKeyToQueryParam).join('&');
if (queryParams) url += '&' + queryParams;
}
return splitHttpClient(url, undefined, telemetryTracker.trackHttp(TOKEN));
},

fetchSplitChanges(since: number, noCache?: boolean, till?: number, rbSince?: number) {
const url = `${urls.sdk}/splitChanges?s=${settings.sync.flagSpecVersion}&since=${since}${rbSince ? '&rbSince=' + rbSince : ''}${filterQueryString || ''}${till ? '&till=' + till : ''}`;
return splitHttpClient(url, noCache ? noCacheHeaderOptions : undefined, telemetryTracker.trackHttp(SPLITS))
Expand Down
Loading