Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
7d77fac
Draft implementation of loadData and getSnapshot methods
EmilianoSanchez Oct 9, 2024
4168532
Merge branch 'breaking_changes_baseline' into data_loader_for_ssr
EmilianoSanchez Oct 18, 2024
248b1b1
Merge branch 'breaking_changes_baseline' into data_loader_for_ssr
EmilianoSanchez Oct 18, 2024
b8b12cd
Update data loader to support memberships
EmilianoSanchez Oct 18, 2024
325ecda
rc
EmilianoSanchez Oct 18, 2024
a6598a4
Merge branch 'development' into data_loader_for_ssr
EmilianoSanchez Aug 22, 2025
60ccbf8
Add RBSegments
EmilianoSanchez Aug 22, 2025
e618b7f
Polishing
EmilianoSanchez Aug 26, 2025
ed482ae
rc
EmilianoSanchez Aug 26, 2025
b70d121
Polishing
EmilianoSanchez Aug 26, 2025
f7dd0a1
Rename new methods
EmilianoSanchez Aug 26, 2025
b937db5
Remove outdated validation utils
EmilianoSanchez Aug 27, 2025
a95edb9
refactor type definitions
EmilianoSanchez Aug 27, 2025
5b84df3
refactor: restructure rollout plan data format and improve data loading
EmilianoSanchez Sep 2, 2025
c20e74f
refactor: do not mutate FF definitions when parsing matchers
EmilianoSanchez Sep 4, 2025
c65b3d0
refactor: call setRolloutPlan outside storage, to generalize to any s…
EmilianoSanchez Sep 4, 2025
9ddadd6
refactor: mode rollout plan validation
EmilianoSanchez Sep 5, 2025
590daa2
Separate getRolloutPlan and setRolloutPlan for bundle size reduction
EmilianoSanchez Sep 5, 2025
4ee373f
Polishing
EmilianoSanchez Sep 5, 2025
9ddac79
Add data loader utils: getRolloutPlan, setRolloutPlan, validateRollou…
EmilianoSanchez Sep 5, 2025
6f1ff41
Merge branch 'FME-9871-data-loader-utils' into data_loader_for_ssr
EmilianoSanchez Sep 5, 2025
705057d
rc
EmilianoSanchez Sep 5, 2025
09263b2
Stable version
EmilianoSanchez Sep 10, 2025
cf45f70
Fix type definition comment
EmilianoSanchez Sep 10, 2025
acfbc5b
Merge pull request #353 from splitio/data_loader_for_ssr
EmilianoSanchez Sep 10, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGES.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
2.5.0 (September 10, 2025)
- Added `factory.getRolloutPlan()` method for standalone server-side SDKs, which returns the rollout plan snapshot from the storage.
- Added `initialRolloutPlan` configuration option for standalone client-side SDKs, which allows preloading the SDK storage with a snapshot of the rollout plan.

2.4.1 (June 3, 2025)
- Bugfix - Improved the Proxy fallback to flag spec version 1.2 to handle cases where the Proxy does not return an end-of-stream marker in 400 status code responses.

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": "2.4.1",
"version": "2.5.0",
"description": "Split JavaScript SDK common components",
"main": "cjs/index.js",
"module": "esm/index.js",
Expand Down
8 changes: 7 additions & 1 deletion src/sdkClient/sdkClientMethodCS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ import { RETRIEVE_CLIENT_DEFAULT, NEW_SHARED_CLIENT, RETRIEVE_CLIENT_EXISTING, L
import { SDK_SEGMENTS_ARRIVED } from '../readiness/constants';
import { ISdkFactoryContext } from '../sdkFactory/types';
import { buildInstanceId } from './identity';
import { setRolloutPlan } from '../storages/setRolloutPlan';
import { ISegmentsCacheSync } from '../storages/types';

/**
* Factory of client method for the client-side API variant where TT is ignored.
* Therefore, clients don't have a bound TT for the track method.
*/
export function sdkClientMethodCSFactory(params: ISdkFactoryContext): (key?: SplitIO.SplitKey) => SplitIO.IBrowserClient {
const { clients, storage, syncManager, sdkReadinessManager, settings: { core: { key }, log } } = params;
const { clients, storage, syncManager, sdkReadinessManager, settings: { core: { key }, log, initialRolloutPlan } } = params;

const mainClientInstance = clientCSDecorator(
log,
Expand Down Expand Up @@ -56,6 +58,10 @@ export function sdkClientMethodCSFactory(params: ISdkFactoryContext): (key?: Spl
sharedSdkReadiness.readinessManager.segments.emit(SDK_SEGMENTS_ARRIVED);
});

if (sharedStorage && initialRolloutPlan) {
setRolloutPlan(log, initialRolloutPlan, { segments: sharedStorage.segments as ISegmentsCacheSync, largeSegments: sharedStorage.largeSegments as ISegmentsCacheSync }, matchingKey);
}

// 3 possibilities:
// - Standalone mode: both syncManager and sharedSyncManager are defined
// - Consumer mode: both syncManager and sharedSyncManager are undefined
Expand Down
16 changes: 12 additions & 4 deletions src/sdkFactory/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import { strategyOptimizedFactory } from '../trackers/strategy/strategyOptimized
import { strategyNoneFactory } from '../trackers/strategy/strategyNone';
import { uniqueKeysTrackerFactory } from '../trackers/uniqueKeysTracker';
import { DEBUG, OPTIMIZED } from '../utils/constants';
import { setRolloutPlan } from '../storages/setRolloutPlan';
import { IStorageSync } from '../storages/types';
import { getMatching } from '../utils/key';

/**
* Modular SDK factory
Expand All @@ -24,7 +27,7 @@ export function sdkFactory(params: ISdkFactoryParams): SplitIO.ISDK | SplitIO.IA
syncManagerFactory, SignalListener, impressionsObserverFactory,
integrationsManagerFactory, sdkManagerFactory, sdkClientMethodFactory,
filterAdapterFactory, lazyInit } = params;
const { log, sync: { impressionsMode } } = settings;
const { log, sync: { impressionsMode }, initialRolloutPlan, core: { key } } = settings;

// @TODO handle non-recoverable errors, such as, global `fetch` not available, invalid SDK Key, etc.
// On non-recoverable errors, we should mark the SDK as destroyed and not start synchronization.
Expand All @@ -43,7 +46,7 @@ export function sdkFactory(params: ISdkFactoryParams): SplitIO.ISDK | SplitIO.IA

const storage = storageFactory({
settings,
onReadyCb: (error) => {
onReadyCb(error) {
if (error) {
// If storage fails to connect, SDK_READY_TIMED_OUT event is emitted immediately. Review when timeout and non-recoverable errors are reworked
readiness.timeout();
Expand All @@ -52,11 +55,16 @@ export function sdkFactory(params: ISdkFactoryParams): SplitIO.ISDK | SplitIO.IA
readiness.splits.emit(SDK_SPLITS_ARRIVED);
readiness.segments.emit(SDK_SEGMENTS_ARRIVED);
},
onReadyFromCacheCb: () => {
onReadyFromCacheCb() {
readiness.splits.emit(SDK_SPLITS_CACHE_LOADED);
}
});
// @TODO add support for dataloader: `if (params.dataLoader) params.dataLoader(storage);`

if (initialRolloutPlan) {
setRolloutPlan(log, initialRolloutPlan, storage as IStorageSync, key && getMatching(key));
if ((storage as IStorageSync).splits.getChangeNumber() > -1) readiness.splits.emit(SDK_SPLITS_CACHE_LOADED);
}

const clients: Record<string, SplitIO.IBasicClient> = {};
const telemetryTracker = telemetryTrackerFactory(storage.telemetry, platform.now);
const integrationsManager = integrationsManagerFactory && integrationsManagerFactory({ settings, storage, telemetryTracker });
Expand Down
133 changes: 133 additions & 0 deletions src/storages/__tests__/dataLoader.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { InMemoryStorageFactory } from '../inMemory/InMemoryStorage';
import { InMemoryStorageCSFactory } from '../inMemory/InMemoryStorageCS';
import { fullSettings } from '../../utils/settingsValidation/__tests__/settings.mocks';
import { loggerMock } from '../../logger/__tests__/sdkLogger.mock';
import { IRBSegment, ISplit } from '../../dtos/types';

import { validateRolloutPlan, setRolloutPlan } from '../setRolloutPlan';
import { getRolloutPlan } from '../getRolloutPlan';

const otherKey = 'otherKey';
const expectedRolloutPlan = {
splitChanges: {
ff: { d: [{ name: 'split1' }], t: 123, s: -1 },
rbs: { d: [{ name: 'rbs1' }], t: 321, s: -1 }
},
memberships: {
[fullSettings.core.key as string]: { ms: { k: [{ n: 'segment1' }] }, ls: { k: [] } },
[otherKey]: { ms: { k: [{ n: 'segment1' }] }, ls: { k: [] } }
},
segmentChanges: [{
name: 'segment1',
added: [fullSettings.core.key as string, otherKey],
removed: [],
since: -1,
till: 123
}]
};

describe('validateRolloutPlan', () => {
afterEach(() => {
loggerMock.mockClear();
});

test('valid rollout plan and mode', () => {
expect(validateRolloutPlan(loggerMock, { mode: 'standalone', initialRolloutPlan: expectedRolloutPlan } as any)).toEqual(expectedRolloutPlan);
expect(loggerMock.error).not.toHaveBeenCalled();
});

test('invalid rollout plan', () => {
expect(validateRolloutPlan(loggerMock, { mode: 'standalone', initialRolloutPlan: {} } as any)).toBeUndefined();
expect(loggerMock.error).toHaveBeenCalledWith('storage: invalid rollout plan provided');
});

test('invalid mode', () => {
expect(validateRolloutPlan(loggerMock, { mode: 'consumer', initialRolloutPlan: expectedRolloutPlan } as any)).toBeUndefined();
expect(loggerMock.warn).toHaveBeenCalledWith('storage: initial rollout plan is ignored in consumer mode');
});
});

describe('getRolloutPlan & setRolloutPlan (client-side)', () => {
// @ts-expect-error Load server-side storage
const serverStorage = InMemoryStorageFactory({ settings: fullSettings });
serverStorage.splits.update([{ name: 'split1' } as ISplit], [], 123);
serverStorage.rbSegments.update([{ name: 'rbs1' } as IRBSegment], [], 321);
serverStorage.segments.update('segment1', [fullSettings.core.key as string, otherKey], [], 123);

afterEach(() => {
jest.clearAllMocks();
});

test('using preloaded data (no memberships, no segments)', () => {
const rolloutPlan = getRolloutPlan(loggerMock, serverStorage);

// @ts-expect-error Load client-side storage with preloaded data
const clientStorage = InMemoryStorageCSFactory({ settings: fullSettings });
setRolloutPlan(loggerMock, rolloutPlan, clientStorage, fullSettings.core.key as string);

// Shared client storage
const sharedClientStorage = clientStorage.shared!(otherKey);
setRolloutPlan(loggerMock, rolloutPlan, { segments: sharedClientStorage.segments, largeSegments: sharedClientStorage.largeSegments }, otherKey);

expect(clientStorage.segments.getRegisteredSegments()).toEqual([]);
expect(sharedClientStorage.segments.getRegisteredSegments()).toEqual([]);

// Get preloaded data from client-side storage
expect(getRolloutPlan(loggerMock, clientStorage)).toEqual(rolloutPlan);
expect(rolloutPlan).toEqual({ ...expectedRolloutPlan, memberships: undefined, segmentChanges: undefined });
});

test('using preloaded data with memberships', () => {
const rolloutPlan = getRolloutPlan(loggerMock, serverStorage, { keys: [fullSettings.core.key as string, otherKey] });

// @ts-expect-error Load client-side storage with preloaded data
const clientStorage = InMemoryStorageCSFactory({ settings: fullSettings });
setRolloutPlan(loggerMock, rolloutPlan, clientStorage, fullSettings.core.key as string);

// Shared client storage
const sharedClientStorage = clientStorage.shared!(otherKey);
setRolloutPlan(loggerMock, rolloutPlan, { segments: sharedClientStorage.segments, largeSegments: sharedClientStorage.largeSegments }, otherKey);

expect(clientStorage.segments.getRegisteredSegments()).toEqual(['segment1']);
expect(sharedClientStorage.segments.getRegisteredSegments()).toEqual(['segment1']);

// @TODO requires internal storage cache for `shared` storages
// // Get preloaded data from client-side storage
// expect(getRolloutPlan(loggerMock, clientStorage, { keys: [fullSettings.core.key as string, otherKey] })).toEqual(rolloutPlan);
// expect(rolloutPlan).toEqual({ ...expectedRolloutPlan, segmentChanges: undefined });
});

test('using preloaded data with segments', () => {
const rolloutPlan = getRolloutPlan(loggerMock, serverStorage, { exposeSegments: true });

// @ts-expect-error Load client-side storage with preloaded data
const clientStorage = InMemoryStorageCSFactory({ settings: fullSettings });
setRolloutPlan(loggerMock, rolloutPlan, clientStorage, fullSettings.core.key as string);

// Shared client storage
const sharedClientStorage = clientStorage.shared!(otherKey);
setRolloutPlan(loggerMock, rolloutPlan, { segments: sharedClientStorage.segments, largeSegments: sharedClientStorage.largeSegments }, otherKey);

expect(clientStorage.segments.getRegisteredSegments()).toEqual(['segment1']);
expect(sharedClientStorage.segments.getRegisteredSegments()).toEqual(['segment1']);

expect(rolloutPlan).toEqual({ ...expectedRolloutPlan, memberships: undefined });
});

test('using preloaded data with memberships and segments', () => {
const rolloutPlan = getRolloutPlan(loggerMock, serverStorage, { keys: [fullSettings.core.key as string], exposeSegments: true });

// @ts-expect-error Load client-side storage with preloaded data
const clientStorage = InMemoryStorageCSFactory({ settings: fullSettings });
setRolloutPlan(loggerMock, rolloutPlan, clientStorage, fullSettings.core.key as string);

// Shared client storage
const sharedClientStorage = clientStorage.shared!(otherKey);
setRolloutPlan(loggerMock, rolloutPlan, { segments: sharedClientStorage.segments, largeSegments: sharedClientStorage.largeSegments }, otherKey);

expect(clientStorage.segments.getRegisteredSegments()).toEqual(['segment1']); // main client membership is set via the rollout plan `memberships` field
expect(sharedClientStorage.segments.getRegisteredSegments()).toEqual(['segment1']); // shared client membership is set via the rollout plan `segmentChanges` field

expect(rolloutPlan).toEqual({ ...expectedRolloutPlan, memberships: { [fullSettings.core.key as string]: expectedRolloutPlan.memberships![fullSettings.core.key as string] } });
});
});
55 changes: 0 additions & 55 deletions src/storages/dataLoader.ts

This file was deleted.

72 changes: 72 additions & 0 deletions src/storages/getRolloutPlan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import SplitIO from '../../types/splitio';
import { IStorageSync } from './types';
import { setToArray } from '../utils/lang/sets';
import { getMatching } from '../utils/key';
import { ILogger } from '../logger/types';
import { RolloutPlan } from './types';
import { IMembershipsResponse, IMySegmentsResponse } from '../dtos/types';

/**
* Gets the rollout plan snapshot from the given synchronous storage.
*/
export function getRolloutPlan(log: ILogger, storage: IStorageSync, options: SplitIO.RolloutPlanOptions = {}): RolloutPlan {

const { keys, exposeSegments } = options;
const { splits, segments, rbSegments } = storage;

log.debug(`storage: get feature flags${keys ? `, and memberships for keys: ${keys}` : ''}${exposeSegments ? ', and segments' : ''}`);

return {
splitChanges: {
ff: {
t: splits.getChangeNumber(),
s: -1,
d: splits.getAll(),
},
rbs: {
t: rbSegments.getChangeNumber(),
s: -1,
d: rbSegments.getAll(),
}
},
segmentChanges: exposeSegments ? // @ts-ignore accessing private prop
Object.keys(segments.segmentCache).map(segmentName => ({
name: segmentName, // @ts-ignore
added: setToArray(segments.segmentCache[segmentName] as Set<string>),
removed: [],
since: -1,
till: segments.getChangeNumber(segmentName)!
})) :
undefined,
memberships: keys ?
keys.reduce<Record<string, IMembershipsResponse>>((prev, key) => {
const matchingKey = getMatching(key);
if (storage.shared) { // Client-side segments
const sharedStorage = storage.shared(matchingKey);
prev[matchingKey] = {
ms: { // @ts-ignore
k: Object.keys(sharedStorage.segments.segmentCache).map(segmentName => ({ n: segmentName })),
},
ls: sharedStorage.largeSegments ? { // @ts-ignore
k: Object.keys(sharedStorage.largeSegments.segmentCache).map(segmentName => ({ n: segmentName })),
} : undefined
};
} else { // Server-side segments
prev[matchingKey] = {
ms: { // @ts-ignore
k: Object.keys(storage.segments.segmentCache).reduce<IMySegmentsResponse['k']>((prev, segmentName) => { // @ts-ignore
return storage.segments.segmentCache[segmentName].has(matchingKey) ?
prev!.concat({ n: segmentName }) :
prev;
}, [])
},
ls: {
k: []
}
};
}
return prev;
}, {}) :
undefined
};
}
4 changes: 2 additions & 2 deletions src/storages/inLocalStorage/validateCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const MILLIS_IN_A_DAY = 86400000;
* @returns `true` if cache should be cleared, `false` otherwise
*/
function validateExpiration(options: SplitIO.InLocalStorageOptions, settings: ISettings, keys: KeyBuilderCS, currentTimestamp: number, isThereCache: boolean) {
const { log } = settings;
const { log, initialRolloutPlan } = settings;

// Check expiration
const lastUpdatedTimestamp = parseInt(localStorage.getItem(keys.buildLastUpdatedKey()) as string, 10);
Expand All @@ -41,7 +41,7 @@ function validateExpiration(options: SplitIO.InLocalStorageOptions, settings: IS
} catch (e) {
log.error(LOG_PREFIX + e);
}
if (isThereCache) {
if (isThereCache && !initialRolloutPlan) {
log.info(LOG_PREFIX + 'SDK key, flags filter criteria, or flags spec version has changed. Cleaning up cache');
return true;
}
Expand Down
Loading