diff --git a/CHANGES.txt b/CHANGES.txt index 6da11eb6..16d0c026 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -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. diff --git a/package-lock.json b/package-lock.json index 4641572c..475804b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@splitsoftware/splitio-commons", - "version": "3.1.1-rc.4", + "version": "3.1.1-rc.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@splitsoftware/splitio-commons", - "version": "3.1.1-rc.4", + "version": "3.1.1-rc.7", "license": "Apache-2.0", "dependencies": { "@types/ioredis": "^4.28.0", diff --git a/package.json b/package.json index 861726e6..7b767d0b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/__tests__/testUtils/jwt.ts b/src/__tests__/testUtils/jwt.ts index b0623c5b..f9810032 100644 --- a/src/__tests__/testUtils/jwt.ts +++ b/src/__tests__/testUtils/jwt.ts @@ -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"]}' }; diff --git a/src/presets/serverSide.ts b/src/presets/serverSide.ts index bfbe62f6..fb3902b2 100644 --- a/src/presets/serverSide.ts +++ b/src/presets/serverSide.ts @@ -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'; @@ -11,7 +11,7 @@ const syncManagerOnlineSSFactory = syncManagerOnlineFactory(pollingManagerSSFact export const serverSideModules = { storageFactory: InMemoryStorageFactory, - serviceApiFactory, + serviceApiFactory: splitApiFactory, syncManagerFactory: syncManagerOnlineSSFactory, sdkManagerFactory, sdkClientMethodFactory, diff --git a/src/sdkClient/sdkLifecycle.ts b/src/sdkClient/sdkLifecycle.ts index 6e4d1b95..10ac33b2 100644 --- a/src/sdkClient/sdkLifecycle.ts +++ b/src/sdkClient/sdkLifecycle.ts @@ -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(); }); diff --git a/src/services/__tests__/secureSplitHttpClient.spec.ts b/src/services/__tests__/secureSplitHttpClient.spec.ts index bc3d1307..cb2f28b2 100644 --- a/src/services/__tests__/secureSplitHttpClient.spec.ts +++ b/src/services/__tests__/secureSplitHttpClient.spec.ts @@ -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'; @@ -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 }; } diff --git a/src/services/__tests__/splitApi.spec.ts b/src/services/__tests__/splitApi.spec.ts index a77c1708..f3b82780 100644 --- a/src/services/__tests__/splitApi.spec.ts +++ b/src/services/__tests__/splitApi.spec.ts @@ -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']); @@ -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(); diff --git a/src/services/authProvider.ts b/src/services/authProvider.ts index 1816bbd5..efd4c47a 100644 --- a/src/services/authProvider.ts +++ b/src/services/authProvider.ts @@ -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'; @@ -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; + /** + * 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; + /** + * 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; } @@ -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 | undefined; + let cachedCredential: IJwtCredential | undefined; + let inFlightPromise: Promise | undefined; let stopped = false; - function fetchCredential(): Promise { - return authenticate().then((credential: IJwtCredentialV3) => { + function fetchCredential(): Promise { + return fetchAuth().then((credential: IJwtCredential) => { log.info(LOG_PREFIX_SYNC_AUTH + 'credential fetched successfully'); cachedCredential = credential; inFlightPromise = undefined; @@ -62,7 +68,7 @@ export function authProviderFactory(settings: ISettings, splitHttpClient: ISplit } return { - credential(): Promise { + credential(): Promise { if (cachedCredential && !isExpired(cachedCredential)) { return Promise.resolve(cachedCredential); } diff --git a/src/services/secureSplitHttpClient.ts b/src/services/secureSplitHttpClient.ts index 8c7273c9..660a57f7 100644 --- a/src/services/secureSplitHttpClient.ts +++ b/src/services/secureSplitHttpClient.ts @@ -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, 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 { - 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 { + 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 { + const httpClient = function (url: string, options?: IRequestOptions, latencyTracker?: (error?: NetworkError) => void, logErrorsAsInfo?: boolean, newVersionHeader = true, useJwt = true): Promise { 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; diff --git a/src/services/serviceApi.ts b/src/services/serviceApi.ts index 207b3b08..f97f67a6 100644 --- a/src/services/serviceApi.ts +++ b/src/services/serviceApi.ts @@ -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, telemetryTracker: ITelemetryTracker, - secureSplitHttpClientFactory?: (settings: ISettings, platform: Pick, 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`; @@ -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)) @@ -99,7 +102,7 @@ export function serviceApiFactory( */ postEventsBulk(body: string, headers?: Record) { 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); }, /** @@ -110,10 +113,10 @@ export function serviceApiFactory( */ postTestImpressionsBulk(body: string, headers?: Record) { 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); }, /** @@ -124,7 +127,7 @@ export function serviceApiFactory( */ postTestImpressionsCount(body: string, headers?: Record) { 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); }, /** @@ -135,7 +138,7 @@ export function serviceApiFactory( */ postUniqueKeysBulkCs(body: string, headers?: Record) { 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); }, /** @@ -146,7 +149,7 @@ export function serviceApiFactory( */ postUniqueKeysBulkSs(body: string, headers?: Record) { 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) { diff --git a/src/services/splitApi.ts b/src/services/splitApi.ts index ba7ff38c..21b02245 100644 --- a/src/services/splitApi.ts +++ b/src/services/splitApi.ts @@ -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' } }; @@ -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`; @@ -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)) diff --git a/src/services/types.ts b/src/services/types.ts index 50bd3f30..2866ba3e 100644 --- a/src/services/types.ts +++ b/src/services/types.ts @@ -1,3 +1,5 @@ +import { IFetchAuth } from '../sync/streaming/AuthClient/types'; + export type IRequestOptions = { method?: string, headers?: Record, @@ -33,9 +35,7 @@ export type IHealthCheckAPI = () => Promise export type ISplitHttpClient = (url: string, options?: IRequestOptions, latencyTracker?: (error?: NetworkError) => void, logErrorsAsInfo?: boolean, newVersionHeader?: boolean) => Promise -export type ISecureSplitHttpClient = ISplitHttpClient & { stop(): void } - -export type IFetchAuth = (userKeys?: string[]) => Promise +export type ISecureSplitHttpClient = ((url: string, options?: IRequestOptions, latencyTracker?: (error?: NetworkError) => void, logErrorsAsInfo?: boolean, newVersionHeader?: boolean, useJwt?: boolean) => Promise) & { stop(): void } export type IFetchDefinitionChanges = (since: number, noCache?: boolean, till?: number, rbSince?: number) => Promise @@ -73,7 +73,7 @@ export interface IServiceApi { postTestImpressionsCount: IPostTestImpressionsCount postMetricsConfig: IPostMetricsConfig postMetricsUsage: IPostMetricsUsage - // lifecycle + // lifecycle: stops authProvider backoff retries stop(): void } diff --git a/src/sync/polling/updaters/definitionChangesUpdater.ts b/src/sync/polling/updaters/definitionChangesUpdater.ts index 45852e74..f6573e73 100644 --- a/src/sync/polling/updaters/definitionChangesUpdater.ts +++ b/src/sync/polling/updaters/definitionChangesUpdater.ts @@ -9,7 +9,7 @@ import { SYNC_FETCH, SYNC_UPDATE, SYNC_FETCH_FAILS, SYNC_FETCH_RETRY } from '../ import { startsWith } from '../../../utils/lang'; import { IN_RULE_BASED_SEGMENT, IN_SEGMENT, RULE_BASED_SEGMENT, STANDARD_SEGMENT } from '../../../utils/constants'; import { setToArray } from '../../../utils/lang/sets'; -import { SPLIT_UPDATE } from '../../streaming/constants'; +import { RB_SEGMENT_UPDATE } from '../../streaming/constants'; import { SdkUpdateMetadata } from '../../../../types/splitio'; import { ISplit } from '../fetchers/splitChangesFetcher'; import { ISegmentsSyncTask } from '../types'; @@ -154,14 +154,14 @@ export function definitionChangesUpdaterFactory( return Promise.resolve( instantUpdate ? - instantUpdate.type === SPLIT_UPDATE ? + instantUpdate.type === RB_SEGMENT_UPDATE ? + { rbs: convertInstantUpdateToDefinitionChanges(instantUpdate) as IDefinitionChangesResponse['rbs'] } : // IFFU edge case: a change to definition that adds an IN_RULE_BASED_SEGMENT matcher that is not present yet Promise.resolve(rbSegments.contains(parseSegments(instantUpdate.payload, IN_RULE_BASED_SEGMENT))).then((contains) => { return contains ? { d: convertInstantUpdateToDefinitionChanges(instantUpdate) as IDefinitionChangesResponse['d'] } : definitionChangesFetcher(since, noCache, till, rbSince, _promiseDecorator); }) : - { rbs: convertInstantUpdateToDefinitionChanges(instantUpdate) as IDefinitionChangesResponse['rbs'] } : definitionChangesFetcher(since, noCache, till, rbSince, _promiseDecorator) ) .then((definitionChanges: IDefinitionChangesResponse) => { diff --git a/src/sync/streaming/AuthClient/__tests__/index.spec.ts b/src/sync/streaming/AuthClient/__tests__/index.spec.ts index 54bb03cb..5264f145 100644 --- a/src/sync/streaming/AuthClient/__tests__/index.spec.ts +++ b/src/sync/streaming/AuthClient/__tests__/index.spec.ts @@ -1,23 +1,14 @@ // mocks and dependencies import { splitApiFactory } from '../../../../services/splitApi'; -import { authDataResponseSample, authDataSample, jwtSampleInvalid, jwtSampleNoChannels, jwtSampleNoIat, userKeySample, userKeyBase64HashSample } from '../../__tests__/dataMocks'; +import { authDataResponseSample, authDataSample, jwtSampleInvalid, jwtSampleNoChannels, jwtSampleNoIat } from '../../__tests__/dataMocks'; import fetchMock from '../../../../__tests__/testUtils/fetchMock'; import { settingsServiceApi } from '../../../../utils/settingsValidation/__tests__/settings.mocks'; import { telemetryTrackerFactory } from '../../../../trackers/telemetryTracker'; -// module to test -import { authenticateFactory, hashUserKey } from '../index'; - const authorizationKey = settingsServiceApi.core.authorizationKey; const authUrl = settingsServiceApi.urls.auth; // @ts-ignore const serviceApi = splitApiFactory(settingsServiceApi, { getFetch: () => fetchMock }, telemetryTrackerFactory()); -const authenticate = authenticateFactory(serviceApi.fetchAuth); - -test('hashUserKey', () => { - - expect(hashUserKey(userKeySample)).toBe(userKeyBase64HashSample); // hashes key and encodes to base64 - -}); +const authenticate = serviceApi.fetchAuth; test('authenticate / success in node (200)', done => { diff --git a/src/sync/streaming/AuthClient/index.ts b/src/sync/streaming/AuthClient/index.ts index 71f0ab62..db7692e2 100644 --- a/src/sync/streaming/AuthClient/index.ts +++ b/src/sync/streaming/AuthClient/index.ts @@ -1,22 +1,20 @@ -import { IFetchAuth } from '../../../services/types'; -import { IAuthenticate, IJwtCredentialV2 } from './types'; +import { IJwtCredential, IFetchAuth } from './types'; import { objectAssign } from '../../../utils/lang/objectAssign'; -import { encodeToBase64 } from '../../../utils/base64'; import { decodeJWTtoken } from '../../../utils/jwt'; -import { hash } from '../../../utils/murmur3/murmur3'; +import { IResponse } from '../../../services/types'; /** * Factory of authentication function. * - * @param fetchAuth - `ServiceApi.fetchAuth` endpoint + * @param fetchAuth - /auth endpoint */ -export function authenticateFactory(fetchAuth: IFetchAuth): IAuthenticate { +export function fetchAuthFactory(fetchAuth: (userKeys?: string[]) => Promise): IFetchAuth { /** * Run authentication requests to Auth Server, and returns a promise that resolves with the decoded JTW token. * @param userKeys - set of user Keys to track membership updates. It is undefined for server-side API. */ - return function authenticate(userKeys?: string[]): Promise { + return function authenticate(userKeys?: string[]): Promise { return fetchAuth(userKeys) .then(resp => resp.json()) .then(json => { @@ -24,19 +22,20 @@ export function authenticateFactory(fetchAuth: IFetchAuth): IAuthenticate { const decodedToken = decodeJWTtoken(json.token); if (typeof decodedToken.iat !== 'number' || typeof decodedToken.exp !== 'number') throw new Error('token properties "issuedAt" (iat) or "expiration" (exp) are missing or invalid'); const channels = JSON.parse(decodedToken['x-ably-capability']); - return objectAssign({ + const credential = objectAssign({ decodedToken, channels }, json); + // The `/api/v3/auth` endpoint nests the streaming settings under `config.streaming`. Normalize + // them into the flat `pushEnabled`/`connDelay` properties used by the PushManager. + const streaming = credential.config && credential.config.streaming; + if (streaming) { + credential.pushEnabled = streaming.enabled; + credential.connDelay = streaming.delay; + } + return credential; } return json; }); }; } - -/** - * Returns the hash of a given user key - */ -export function hashUserKey(userKey: string): string { - return encodeToBase64(hash(userKey, 0).toString()); -} diff --git a/src/sync/streaming/AuthClient/types.ts b/src/sync/streaming/AuthClient/types.ts index ae98c812..1c982a75 100644 --- a/src/sync/streaming/AuthClient/types.ts +++ b/src/sync/streaming/AuthClient/types.ts @@ -1,23 +1,19 @@ import { IDecodedJWTToken } from '../../../utils/jwt/types'; -export type IJwtCredentialV2 = { - pushEnabled: boolean - token: string // empty string ("") when `"pushEnabled": false` - decodedToken: IDecodedJWTToken - channels: { [channel: string]: string[] } - connDelay?: number -} - -export type IJwtCredentialV3 = { - token: string +export type IJwtCredential = { + token: string; // empty string ("") when `"pushEnabled": false` decodedToken: IDecodedJWTToken channels: { [channel: string]: string[] } + // /api/v2/auth fields + pushEnabled?: boolean | null; + connDelay?: number | null; + // /api/v3/auth fields config?: { streaming?: { - delay?: number - enabled?: boolean + delay?: number | null; + enabled?: boolean | null; } | null; } | null; } -export type IAuthenticate = (userKeys?: string[]) => Promise +export type IFetchAuth = (userKeys?: string[]) => Promise diff --git a/src/sync/streaming/SSEClient/index.ts b/src/sync/streaming/SSEClient/index.ts index 1f31d5a3..fba4a736 100644 --- a/src/sync/streaming/SSEClient/index.ts +++ b/src/sync/streaming/SSEClient/index.ts @@ -5,7 +5,7 @@ import { ISettings } from '../../../types'; import { checkIfServerSide } from '../../../utils/key'; import { isString } from '../../../utils/lang'; import { objectAssign } from '../../../utils/lang/objectAssign'; -import { IJwtCredentialV2 } from '../AuthClient/types'; +import { IJwtCredential } from '../AuthClient/types'; import { ISSEClient, ISseEventHandler } from './types'; const ABLY_API_VERSION = '1.1'; @@ -66,7 +66,7 @@ export class SSEClient implements ISSEClient { /** * Open the connection with a given authToken */ - open(authToken: IJwtCredentialV2) { + open(authToken: IJwtCredential) { this.close(); // it closes connection if previously opened const channelsQueryParam = Object.keys(authToken.channels).map((channel) => { diff --git a/src/sync/streaming/SSEClient/types.ts b/src/sync/streaming/SSEClient/types.ts index 8072c05b..999ff30a 100644 --- a/src/sync/streaming/SSEClient/types.ts +++ b/src/sync/streaming/SSEClient/types.ts @@ -1,4 +1,4 @@ -import { IJwtCredentialV2 } from '../AuthClient/types'; +import { IJwtCredential } from '../AuthClient/types'; export interface ISseEventHandler { handleError: (ev: Event) => any; @@ -7,7 +7,7 @@ export interface ISseEventHandler { } export interface ISSEClient { - open(authToken: IJwtCredentialV2): void, + open(authToken: IJwtCredential): void, close(): void, setEventHandler(handler: ISseEventHandler): void } diff --git a/src/sync/streaming/SSEHandler/index.ts b/src/sync/streaming/SSEHandler/index.ts index 22d462b8..3e3388c2 100644 --- a/src/sync/streaming/SSEHandler/index.ts +++ b/src/sync/streaming/SSEHandler/index.ts @@ -1,6 +1,6 @@ import { errorParser, messageParser } from './NotificationParser'; import { notificationKeeperFactory } from './NotificationKeeper'; -import { PUSH_RETRYABLE_ERROR, PUSH_NON_RETRYABLE_ERROR, OCCUPANCY, CONTROL, SEGMENT_UPDATE, SPLIT_KILL, SPLIT_UPDATE, MEMBERSHIPS_MS_UPDATE, MEMBERSHIPS_LS_UPDATE, RB_SEGMENT_UPDATE } from '../constants'; +import { PUSH_RETRYABLE_ERROR, PUSH_NON_RETRYABLE_ERROR, OCCUPANCY, CONTROL, SEGMENT_UPDATE, SPLIT_KILL, SPLIT_UPDATE, CONFIG_UPDATE, MEMBERSHIPS_MS_UPDATE, MEMBERSHIPS_LS_UPDATE, RB_SEGMENT_UPDATE } from '../constants'; import { IPushEventEmitter } from '../types'; import { ISseEventHandler } from '../SSEClient/types'; import { INotificationError, INotificationMessage } from './types'; @@ -80,6 +80,7 @@ export function SSEHandlerFactory(log: ILogger, pushEmitter: IPushEventEmitter, switch (parsedData.type) { /* update events */ case SPLIT_UPDATE: + case CONFIG_UPDATE: case SEGMENT_UPDATE: case MEMBERSHIPS_MS_UPDATE: case MEMBERSHIPS_LS_UPDATE: diff --git a/src/sync/streaming/SSEHandler/types.ts b/src/sync/streaming/SSEHandler/types.ts index a39b8000..144acac3 100644 --- a/src/sync/streaming/SSEHandler/types.ts +++ b/src/sync/streaming/SSEHandler/types.ts @@ -1,5 +1,5 @@ import { ControlType } from '../constants'; -import { SEGMENT_UPDATE, SPLIT_UPDATE, SPLIT_KILL, CONTROL, OCCUPANCY, MEMBERSHIPS_LS_UPDATE, MEMBERSHIPS_MS_UPDATE, RB_SEGMENT_UPDATE } from '../types'; +import { SEGMENT_UPDATE, SPLIT_UPDATE, CONFIG_UPDATE, SPLIT_KILL, CONTROL, OCCUPANCY, MEMBERSHIPS_LS_UPDATE, MEMBERSHIPS_MS_UPDATE, RB_SEGMENT_UPDATE } from '../types'; export enum Compression { None = 0, @@ -42,7 +42,7 @@ export interface ISegmentUpdateData { } export interface ISplitUpdateData { - type: SPLIT_UPDATE | RB_SEGMENT_UPDATE, + type: SPLIT_UPDATE | CONFIG_UPDATE | RB_SEGMENT_UPDATE, changeNumber: number, pcn?: number, d?: string, diff --git a/src/sync/streaming/__tests__/dataMocks.ts b/src/sync/streaming/__tests__/dataMocks.ts index cb7007d8..2a1ebf6f 100644 --- a/src/sync/streaming/__tests__/dataMocks.ts +++ b/src/sync/streaming/__tests__/dataMocks.ts @@ -39,10 +39,6 @@ export const authDataSample = { channels: parsedChannelsSample, }; -export const userKeySample = 'emi@split.io'; - -export const userKeyBase64HashSample = 'MjAxNjU2NDU5Mw=='; - export const channelsQueryParamSample = 'NzM2MDI5Mzc0_MzQyODU4NDUyNg%3D%3D_segments,NzM2MDI5Mzc0_MzQyODU4NDUyNg%3D%3D_splits,control'; export const keylists = [ diff --git a/src/sync/streaming/constants.ts b/src/sync/streaming/constants.ts index ce7215bf..33f24f92 100644 --- a/src/sync/streaming/constants.ts +++ b/src/sync/streaming/constants.ts @@ -30,6 +30,7 @@ export const MEMBERSHIPS_LS_UPDATE = 'MEMBERSHIPS_LS_UPDATE'; export const SEGMENT_UPDATE = 'SEGMENT_UPDATE'; export const SPLIT_KILL = 'SPLIT_KILL'; export const SPLIT_UPDATE = 'SPLIT_UPDATE'; +export const CONFIG_UPDATE = 'CONFIG_UPDATE'; export const RB_SEGMENT_UPDATE = 'RB_SEGMENT_UPDATE'; // Control-type push notifications, handled by NotificationKeeper diff --git a/src/sync/streaming/pushManager.ts b/src/sync/streaming/pushManager.ts index 1822ffb8..5c1efefe 100644 --- a/src/sync/streaming/pushManager.ts +++ b/src/sync/streaming/pushManager.ts @@ -7,16 +7,15 @@ import { SSEHandlerFactory } from './SSEHandler'; import { MySegmentsUpdateWorker } from './UpdateWorkers/MySegmentsUpdateWorker'; import { SegmentsUpdateWorker } from './UpdateWorkers/SegmentsUpdateWorker'; import { DefinitionsUpdateWorker } from './UpdateWorkers/DefinitionsUpdateWorker'; -import { authenticateFactory, hashUserKey } from './AuthClient'; import { forOwn } from '../../utils/lang'; import { SSEClient } from './SSEClient'; import { checkIfServerSide, getMatching } from '../../utils/key'; -import { MEMBERSHIPS_MS_UPDATE, MEMBERSHIPS_LS_UPDATE, PUSH_NON_RETRYABLE_ERROR, PUSH_SUBSYSTEM_DOWN, SECONDS_BEFORE_EXPIRATION, SEGMENT_UPDATE, SPLIT_KILL, SPLIT_UPDATE, RB_SEGMENT_UPDATE, PUSH_RETRYABLE_ERROR, PUSH_SUBSYSTEM_UP, ControlType } from './constants'; +import { MEMBERSHIPS_MS_UPDATE, MEMBERSHIPS_LS_UPDATE, PUSH_NON_RETRYABLE_ERROR, PUSH_SUBSYSTEM_DOWN, SECONDS_BEFORE_EXPIRATION, SEGMENT_UPDATE, SPLIT_KILL, SPLIT_UPDATE, CONFIG_UPDATE, RB_SEGMENT_UPDATE, PUSH_RETRYABLE_ERROR, PUSH_SUBSYSTEM_UP, ControlType } from './constants'; import { STREAMING_FALLBACK, STREAMING_REFRESH_TOKEN, STREAMING_CONNECTING, STREAMING_DISABLED, ERROR_STREAMING_AUTH, STREAMING_DISCONNECTING, STREAMING_RECONNECT, STREAMING_PARSING_MEMBERSHIPS_UPDATE } from '../../logger/constants'; import { IMembershipMSUpdateData, IMembershipLSUpdateData, KeyList, UpdateStrategy } from './SSEHandler/types'; import { getDelay, isInBitmap, parseBitmap, parseCompressedData } from './parseUtils'; import { Hash64, hash64 } from '../../utils/murmur3/murmur3_64'; -import { IJwtCredentialV2 } from './AuthClient/types'; +import { IJwtCredential } from './AuthClient/types'; import { TOKEN_REFRESH, AUTH_REJECTION } from '../../utils/constants'; import { ISdkFactoryContextSync } from '../../sdkFactory/types'; @@ -45,7 +44,6 @@ export function pushManagerFactory( log.warn(STREAMING_FALLBACK, [e]); return; } - const authenticate = authenticateFactory(serviceApi.fetchAuth); // init feedback loop const pushEmitter = new platform.EventEmitter() as IPushEventEmitter; @@ -58,8 +56,6 @@ export function pushManagerFactory( // For server-side we pass the segmentsSyncTask, used by DefinitionsUpdateWorker to fetch new segments const definitionsUpdateWorker = DefinitionsUpdateWorker(log, storage, pollingManager.definitionsSyncTask, readiness.definitions, telemetryTracker); - // [Only for client-side] map of hashes to user keys, to dispatch membership update events to the corresponding MySegmentsUpdateWorker - const userKeyHashes: Record = {}; // [Only for client-side] map of user keys to their corresponding hash64 and MySegmentsUpdateWorkers. // Hash64 is used to process membership update events and dispatch actions to the corresponding MySegmentsUpdateWorker. const clients: Record }> = {}; @@ -81,7 +77,7 @@ export function pushManagerFactory( let timeoutIdTokenRefresh: ReturnType; let timeoutIdSseOpen: ReturnType; - function scheduleTokenRefreshAndSse(authData: IJwtCredentialV2) { + function scheduleTokenRefreshAndSse(authData: IJwtCredential) { // clear scheduled tasks if exist if (timeoutIdTokenRefresh) clearTimeout(timeoutIdTokenRefresh); if (timeoutIdSseOpen) clearTimeout(timeoutIdSseOpen); @@ -113,41 +109,37 @@ export function pushManagerFactory( disconnected = false; const userKeys = userKey ? Object.keys(clients) : undefined; - authenticate(userKeys).then( - function (authData) { - if (disconnected) return; - - // 'pushEnabled: false' is handled as a PUSH_NON_RETRYABLE_ERROR instead of PUSH_SUBSYSTEM_DOWN, in order to - // close the sseClient in case the org has been bloqued while the instance was connected to streaming - if (!authData.pushEnabled) { - log.info(STREAMING_DISABLED); - pushEmitter.emit(PUSH_NON_RETRYABLE_ERROR); - return; - } - - // [Only for client-side] don't open SSE connection if a new shared client was added, since it means that a new authentication is taking place - if (userKeys && userKeys.length < Object.keys(clients).length) return; + serviceApi.fetchAuth(userKeys).then((authData) => { + if (disconnected) return; - // Schedule SSE connection and refresh token - scheduleTokenRefreshAndSse(authData); + // 'pushEnabled: false' is handled as a PUSH_NON_RETRYABLE_ERROR instead of PUSH_SUBSYSTEM_DOWN, in order to + // close the sseClient in case the org has been bloqued while the instance was connected to streaming + if (!authData.pushEnabled) { + log.info(STREAMING_DISABLED); + pushEmitter.emit(PUSH_NON_RETRYABLE_ERROR); + return; } - ).catch( - function (error) { - if (disconnected) return; - log.error(ERROR_STREAMING_AUTH, [error.message]); + // [Only for client-side] don't open SSE connection if a new shared client was added, since it means that a new authentication is taking place + if (userKeys && userKeys.length < Object.keys(clients).length) return; - // Handle 4XX HTTP errors: 401 (invalid SDK Key) or 400 (using incorrect SDK Key, i.e., client-side SDK Key on server-side) - if (error.statusCode >= 400 && error.statusCode < 500) { - telemetryTracker.streamingEvent(AUTH_REJECTION); - pushEmitter.emit(PUSH_NON_RETRYABLE_ERROR); - return; - } + // Schedule SSE connection and refresh token + scheduleTokenRefreshAndSse(authData); + }).catch((error) => { + if (disconnected) return; - // Handle other HTTP and network errors as recoverable errors - pushEmitter.emit(PUSH_RETRYABLE_ERROR); + log.error(ERROR_STREAMING_AUTH, [error.message]); + + // Handle 4XX HTTP errors: 401 (invalid SDK Key) or 400 (using incorrect SDK Key, i.e., client-side SDK Key on server-side) + if (error.statusCode >= 400 && error.statusCode < 500) { + telemetryTracker.streamingEvent(AUTH_REJECTION); + pushEmitter.emit(PUSH_NON_RETRYABLE_ERROR); + return; } - ); + + // Handle other HTTP and network errors as recoverable errors + pushEmitter.emit(PUSH_RETRYABLE_ERROR); + }); } // close SSE connection and cancel scheduled tasks @@ -218,8 +210,11 @@ export function pushManagerFactory( /** Functions related to synchronization (Queues and Workers in the spec) */ + // Some notification types are specific to the Feature flags or Configs SDKs. Their handlers are + // registered unconditionally, since the SDK only subscribes to the channels of its own entities. pushEmitter.on(SPLIT_KILL, definitionsUpdateWorker.killDefinition); pushEmitter.on(SPLIT_UPDATE, definitionsUpdateWorker.put); + pushEmitter.on(CONFIG_UPDATE, definitionsUpdateWorker.put); pushEmitter.on(RB_SEGMENT_UPDATE, definitionsUpdateWorker.put); function handleMySegmentsUpdate(parsedData: IMembershipMSUpdateData | IMembershipLSUpdateData) { @@ -324,10 +319,7 @@ export function pushManagerFactory( // [Only for client-side] add(userKey: string, mySegmentsSyncTask: IMySegmentsSyncTask) { - const hash = hashUserKey(userKey); - - if (!userKeyHashes[hash]) { - userKeyHashes[hash] = userKey; + if (!clients[userKey]) { clients[userKey] = { hash64: hash64(userKey), worker: MySegmentsUpdateWorker(log, storage, mySegmentsSyncTask, telemetryTracker) @@ -349,8 +341,6 @@ export function pushManagerFactory( }, // [Only for client-side] remove(userKey: string) { - const hash = hashUserKey(userKey); - delete userKeyHashes[hash]; delete clients[userKey]; } } diff --git a/src/sync/streaming/types.ts b/src/sync/streaming/types.ts index 00e3fb67..61bd5225 100644 --- a/src/sync/streaming/types.ts +++ b/src/sync/streaming/types.ts @@ -16,19 +16,20 @@ export type MEMBERSHIPS_LS_UPDATE = 'MEMBERSHIPS_LS_UPDATE'; export type SEGMENT_UPDATE = 'SEGMENT_UPDATE'; export type SPLIT_KILL = 'SPLIT_KILL'; export type SPLIT_UPDATE = 'SPLIT_UPDATE'; +export type CONFIG_UPDATE = 'CONFIG_UPDATE'; export type RB_SEGMENT_UPDATE = 'RB_SEGMENT_UPDATE'; // Control-type push notifications, handled by NotificationKeeper export type CONTROL = 'CONTROL'; export type OCCUPANCY = 'OCCUPANCY'; -export type IPushEvent = PUSH_SUBSYSTEM_UP | PUSH_SUBSYSTEM_DOWN | PUSH_NON_RETRYABLE_ERROR | PUSH_RETRYABLE_ERROR | MEMBERSHIPS_MS_UPDATE | MEMBERSHIPS_LS_UPDATE | SEGMENT_UPDATE | SPLIT_UPDATE | SPLIT_KILL | RB_SEGMENT_UPDATE | ControlType.STREAMING_RESET +export type IPushEvent = PUSH_SUBSYSTEM_UP | PUSH_SUBSYSTEM_DOWN | PUSH_NON_RETRYABLE_ERROR | PUSH_RETRYABLE_ERROR | MEMBERSHIPS_MS_UPDATE | MEMBERSHIPS_LS_UPDATE | SEGMENT_UPDATE | SPLIT_UPDATE | CONFIG_UPDATE | SPLIT_KILL | RB_SEGMENT_UPDATE | ControlType.STREAMING_RESET type IParsedData = T extends MEMBERSHIPS_MS_UPDATE ? IMembershipMSUpdateData : T extends MEMBERSHIPS_LS_UPDATE ? IMembershipLSUpdateData : T extends SEGMENT_UPDATE ? ISegmentUpdateData : - T extends SPLIT_UPDATE | RB_SEGMENT_UPDATE ? ISplitUpdateData : + T extends SPLIT_UPDATE | CONFIG_UPDATE | RB_SEGMENT_UPDATE ? ISplitUpdateData : T extends SPLIT_KILL ? ISplitKillData : INotificationData; /**