diff --git a/CHANGES.txt b/CHANGES.txt index 4a80a5e8..da38cdb1 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,6 @@ +2.6.0 (September 18, 2025) + - Added `storage.wrapper` configuration option to allow the SDK to use a custom storage wrapper for the storage type `LOCALSTORAGE`. Default value is `window.localStorage`. + 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. @@ -51,6 +54,15 @@ - Removed internal ponyfills for `Map` and `Set` global objects, dropping support for IE and other outdated browsers. The SDK now requires the runtime environment to support these features natively or to provide a polyfill. - Removed the `sync.localhostMode` configuration option to plug the LocalhostMode module. +1.17.1 (July 25, 2025) + - Updated the Redis storage to avoid lazy require of the `ioredis` dependency when the SDK is initialized. + - Updated some transitive dependencies for vulnerability fixes. + - Bugfix - Enhanced HTTP client module to implement timeouts for failing requests that might otherwise remain pending indefinitely on some Fetch API implementations, pausing the SDK synchronization process. + - Bugfix - Properly handle rejected promises when using targeting rules with segment matchers in consumer modes (e.g., Redis and Pluggable storages). + - Bugfix - Sanitize the `SplitSDKMachineName` header value to avoid exceptions on HTTP/S requests when it contains non ISO-8859-1 characters (Related to issue https://github.com/splitio/javascript-client/issues/847). + - Bugfix - Fixed an issue with the SDK_UPDATE event on server-side, where it was not being emitted if there was an empty segment and the SDK received a feature flag update notification. + - Bugfix - Fixed an issue with the server-side polling manager that caused dangling timers when the SDK was destroyed before it was ready. + 1.17.0 (September 6, 2024) - Added `sync.requestOptions.getHeaderOverrides` configuration option to enhance SDK HTTP request Headers for Authorization Frameworks. - Added `isTimedout` and `lastUpdate` properties to IStatusInterface to keep track of the timestamp of the last SDK event, used on React and Redux SDKs. diff --git a/README.md b/README.md index 85f791cd..36f809b5 100644 --- a/README.md +++ b/README.md @@ -24,24 +24,24 @@ To learn more about Split, contact hello@split.io, or get started with feature f Split has built and maintains SDKs for: -* .NET [Github](https://github.com/splitio/dotnet-client) [Docs](https://help.split.io/hc/en-us/articles/360020240172--NET-SDK) -* Android [Github](https://github.com/splitio/android-client) [Docs](https://help.split.io/hc/en-us/articles/360020343291-Android-SDK) -* Angular [Github](https://github.com/splitio/angular-sdk-plugin) [Docs](https://help.split.io/hc/en-us/articles/6495326064397-Angular-utilities) -* Elixir thin-client [Github](https://github.com/splitio/elixir-thin-client) [Docs](https://help.split.io/hc/en-us/articles/26988707417869-Elixir-Thin-Client-SDK) -* Flutter [Github](https://github.com/splitio/flutter-sdk-plugin) [Docs](https://help.split.io/hc/en-us/articles/8096158017165-Flutter-plugin) -* GO [Github](https://github.com/splitio/go-client) [Docs](https://help.split.io/hc/en-us/articles/360020093652-Go-SDK) -* iOS [Github](https://github.com/splitio/ios-client) [Docs](https://help.split.io/hc/en-us/articles/360020401491-iOS-SDK) -* Java [Github](https://github.com/splitio/java-client) [Docs](https://help.split.io/hc/en-us/articles/360020405151-Java-SDK) -* JavaScript [Github](https://github.com/splitio/javascript-client) [Docs](https://help.split.io/hc/en-us/articles/360020448791-JavaScript-SDK) -* JavaScript for Browser [Github](https://github.com/splitio/javascript-browser-client) [Docs](https://help.split.io/hc/en-us/articles/360058730852-Browser-SDK) -* Node.js [Github](https://github.com/splitio/javascript-client) [Docs](https://help.split.io/hc/en-us/articles/360020564931-Node-js-SDK) -* PHP [Github](https://github.com/splitio/php-client) [Docs](https://help.split.io/hc/en-us/articles/360020350372-PHP-SDK) -* PHP thin-client [Github](https://github.com/splitio/php-thin-client) [Docs](https://help.split.io/hc/en-us/articles/18305128673933-PHP-Thin-Client-SDK) -* Python [Github](https://github.com/splitio/python-client) [Docs](https://help.split.io/hc/en-us/articles/360020359652-Python-SDK) -* React [Github](https://github.com/splitio/react-client) [Docs](https://help.split.io/hc/en-us/articles/360038825091-React-SDK) -* React Native [Github](https://github.com/splitio/react-native-client) [Docs](https://help.split.io/hc/en-us/articles/4406066357901-React-Native-SDK) -* Redux [Github](https://github.com/splitio/redux-client) [Docs](https://help.split.io/hc/en-us/articles/360038851551-Redux-SDK) -* Ruby [Github](https://github.com/splitio/ruby-client) [Docs](https://help.split.io/hc/en-us/articles/360020673251-Ruby-SDK) +* .NET [Github](https://github.com/splitio/dotnet-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/net-sdk/) +* Android [Github](https://github.com/splitio/android-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/android-sdk/) +* Angular [Github](https://github.com/splitio/angular-sdk-plugin) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/angular-utilities/) +* Elixir thin-client [Github](https://github.com/splitio/elixir-thin-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/elixir-thin-client-sdk/) +* Flutter [Github](https://github.com/splitio/flutter-sdk-plugin) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/flutter-plugin/) +* GO [Github](https://github.com/splitio/go-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/go-sdk/) +* iOS [Github](https://github.com/splitio/ios-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/ios-sdk/) +* Java [Github](https://github.com/splitio/java-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/java-sdk/) +* JavaScript [Github](https://github.com/splitio/javascript-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/javascript-sdk/) +* JavaScript for Browser [Github](https://github.com/splitio/javascript-browser-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/browser-sdk/) +* Node.js [Github](https://github.com/splitio/javascript-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/nodejs-sdk/) +* PHP [Github](https://github.com/splitio/php-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/php-sdk/) +* PHP thin-client [Github](https://github.com/splitio/php-thin-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/php-thin-client-sdk/) +* Python [Github](https://github.com/splitio/python-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/python-sdk/) +* React [Github](https://github.com/splitio/react-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/react-sdk/) +* React Native [Github](https://github.com/splitio/react-native-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/react-native-sdk/) +* Redux [Github](https://github.com/splitio/redux-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/redux-sdk/) +* Ruby [Github](https://github.com/splitio/ruby-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/ruby-sdk/) For a comprehensive list of open source projects visit our [Github page](https://github.com/splitio?utf8=%E2%9C%93&query=%20only%3Apublic%20). diff --git a/package-lock.json b/package-lock.json index 14125ac1..d40871e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@splitsoftware/splitio-commons", - "version": "2.5.0", + "version": "2.6.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@splitsoftware/splitio-commons", - "version": "2.5.0", + "version": "2.6.0", "license": "Apache-2.0", "dependencies": { "@types/ioredis": "^4.28.0", diff --git a/package.json b/package.json index 155f650a..9301b5bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@splitsoftware/splitio-commons", - "version": "2.5.0", + "version": "2.6.0", "description": "Split JavaScript SDK common components", "main": "cjs/index.js", "module": "esm/index.js", diff --git a/src/logger/messages/info.ts b/src/logger/messages/info.ts index 94333bf6..fc688224 100644 --- a/src/logger/messages/info.ts +++ b/src/logger/messages/info.ts @@ -11,7 +11,7 @@ export const codesInfo: [number, string][] = codesWarn.concat([ [c.IMPRESSION, c.LOG_PREFIX_IMPRESSIONS_TRACKER +'Feature flag: %s. Key: %s. Evaluation: %s. Label: %s'], [c.IMPRESSION_QUEUEING, c.LOG_PREFIX_IMPRESSIONS_TRACKER +'Queueing corresponding impression.'], [c.NEW_SHARED_CLIENT, 'New shared client instance created.'], - [c.NEW_FACTORY, 'New Split SDK instance created.'], + [c.NEW_FACTORY, 'New Split SDK instance created. %s'], [c.EVENTS_TRACKER_SUCCESS, c.LOG_PREFIX_EVENTS_TRACKER + 'Successfully queued %s'], [c.IMPRESSIONS_TRACKER_SUCCESS, c.LOG_PREFIX_IMPRESSIONS_TRACKER + 'Successfully stored %s impression(s).'], [c.USER_CONSENT_UPDATED, 'UserConsent: consent status changed from %s to %s.'], diff --git a/src/sdkFactory/index.ts b/src/sdkFactory/index.ts index d1dcac43..eba01028 100644 --- a/src/sdkFactory/index.ts +++ b/src/sdkFactory/index.ts @@ -113,7 +113,7 @@ export function sdkFactory(params: ISdkFactoryParams): SplitIO.ISDK | SplitIO.IA initCallbacks.length = 0; } - log.info(NEW_FACTORY); + log.info(NEW_FACTORY, [settings.version]); // @ts-ignore return objectAssign({ diff --git a/src/storages/__tests__/RBSegmentsCacheSync.spec.ts b/src/storages/__tests__/RBSegmentsCacheSync.spec.ts index 03579351..0946bacf 100644 --- a/src/storages/__tests__/RBSegmentsCacheSync.spec.ts +++ b/src/storages/__tests__/RBSegmentsCacheSync.spec.ts @@ -6,7 +6,8 @@ import { IRBSegmentsCacheSync } from '../types'; import { fullSettings } from '../../utils/settingsValidation/__tests__/settings.mocks'; const cacheInMemory = new RBSegmentsCacheInMemory(); -const cacheInLocal = new RBSegmentsCacheInLocal(fullSettings, new KeyBuilderCS('SPLITIO', 'user')); +// eslint-disable-next-line no-undef +const cacheInLocal = new RBSegmentsCacheInLocal(fullSettings, new KeyBuilderCS('SPLITIO', 'user'), localStorage); describe.each([cacheInMemory, cacheInLocal])('Rule-based segments cache sync (Memory & LocalStorage)', (cache: IRBSegmentsCacheSync) => { diff --git a/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts b/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts index e3b250b5..fd038a07 100644 --- a/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts +++ b/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts @@ -3,16 +3,19 @@ import { isNaNNumber } from '../../utils/lang'; import { AbstractMySegmentsCacheSync } from '../AbstractMySegmentsCacheSync'; import type { MySegmentsKeyBuilder } from '../KeyBuilderCS'; import { LOG_PREFIX, DEFINED } from './constants'; +import { StorageAdapter } from '../types'; export class MySegmentsCacheInLocal extends AbstractMySegmentsCacheSync { private readonly keys: MySegmentsKeyBuilder; private readonly log: ILogger; + private readonly storage: StorageAdapter; - constructor(log: ILogger, keys: MySegmentsKeyBuilder) { + constructor(log: ILogger, keys: MySegmentsKeyBuilder, storage: StorageAdapter) { super(); this.log = log; this.keys = keys; + this.storage = storage; // There is not need to flush segments cache like splits cache, since resetSegments receives the up-to-date list of active segments } @@ -20,8 +23,8 @@ export class MySegmentsCacheInLocal extends AbstractMySegmentsCacheSync { const segmentKey = this.keys.buildSegmentNameKey(name); try { - if (localStorage.getItem(segmentKey) === DEFINED) return false; - localStorage.setItem(segmentKey, DEFINED); + if (this.storage.getItem(segmentKey) === DEFINED) return false; + this.storage.setItem(segmentKey, DEFINED); return true; } catch (e) { this.log.error(LOG_PREFIX + e); @@ -33,8 +36,8 @@ export class MySegmentsCacheInLocal extends AbstractMySegmentsCacheSync { const segmentKey = this.keys.buildSegmentNameKey(name); try { - if (localStorage.getItem(segmentKey) !== DEFINED) return false; - localStorage.removeItem(segmentKey); + if (this.storage.getItem(segmentKey) !== DEFINED) return false; + this.storage.removeItem(segmentKey); return true; } catch (e) { this.log.error(LOG_PREFIX + e); @@ -43,18 +46,16 @@ export class MySegmentsCacheInLocal extends AbstractMySegmentsCacheSync { } isInSegment(name: string): boolean { - return localStorage.getItem(this.keys.buildSegmentNameKey(name)) === DEFINED; + return this.storage.getItem(this.keys.buildSegmentNameKey(name)) === DEFINED; } getRegisteredSegments(): string[] { - // Scan current values from localStorage - return Object.keys(localStorage).reduce((accum, key) => { - let segmentName = this.keys.extractSegmentName(key); - - if (segmentName) accum.push(segmentName); - - return accum; - }, [] as string[]); + const registeredSegments: string[] = []; + for (let i = 0, len = this.storage.length; i < len; i++) { + const segmentName = this.keys.extractSegmentName(this.storage.key(i)!); + if (segmentName) registeredSegments.push(segmentName); + } + return registeredSegments; } getKeysCount() { @@ -63,8 +64,8 @@ export class MySegmentsCacheInLocal extends AbstractMySegmentsCacheSync { protected setChangeNumber(changeNumber?: number) { try { - if (changeNumber) localStorage.setItem(this.keys.buildTillKey(), changeNumber + ''); - else localStorage.removeItem(this.keys.buildTillKey()); + if (changeNumber) this.storage.setItem(this.keys.buildTillKey(), changeNumber + ''); + else this.storage.removeItem(this.keys.buildTillKey()); } catch (e) { this.log.error(e); } @@ -72,7 +73,7 @@ export class MySegmentsCacheInLocal extends AbstractMySegmentsCacheSync { getChangeNumber() { const n = -1; - let value: string | number | null = localStorage.getItem(this.keys.buildTillKey()); + let value: string | number | null = this.storage.getItem(this.keys.buildTillKey()); if (value !== null) { value = parseInt(value, 10); diff --git a/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts b/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts index cfc68cf5..56e8ed41 100644 --- a/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts +++ b/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts @@ -5,22 +5,24 @@ import { isFiniteNumber, isNaNNumber, toNumber } from '../../utils/lang'; import { setToArray } from '../../utils/lang/sets'; import { usesSegments } from '../AbstractSplitsCacheSync'; import { KeyBuilderCS } from '../KeyBuilderCS'; -import { IRBSegmentsCacheSync } from '../types'; +import { IRBSegmentsCacheSync, StorageAdapter } from '../types'; import { LOG_PREFIX } from './constants'; export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync { private readonly keys: KeyBuilderCS; private readonly log: ILogger; + private readonly storage: StorageAdapter; - constructor(settings: ISettings, keys: KeyBuilderCS) { + constructor(settings: ISettings, keys: KeyBuilderCS, storage: StorageAdapter) { this.keys = keys; this.log = settings.log; + this.storage = storage; } clear() { this.getNames().forEach(name => this.remove(name)); - localStorage.removeItem(this.keys.buildRBSegmentsTillKey()); + this.storage.removeItem(this.keys.buildRBSegmentsTillKey()); } update(toAdd: IRBSegment[], toRemove: IRBSegment[], changeNumber: number): boolean { @@ -31,8 +33,8 @@ export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync { private setChangeNumber(changeNumber: number) { try { - localStorage.setItem(this.keys.buildRBSegmentsTillKey(), changeNumber + ''); - localStorage.setItem(this.keys.buildLastUpdatedKey(), Date.now() + ''); + this.storage.setItem(this.keys.buildRBSegmentsTillKey(), changeNumber + ''); + this.storage.setItem(this.keys.buildLastUpdatedKey(), Date.now() + ''); } catch (e) { this.log.error(LOG_PREFIX + e); } @@ -40,20 +42,19 @@ export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync { private updateSegmentCount(diff: number) { const segmentsCountKey = this.keys.buildSplitsWithSegmentCountKey(); - const count = toNumber(localStorage.getItem(segmentsCountKey)) + diff; - // @ts-expect-error - if (count > 0) localStorage.setItem(segmentsCountKey, count); - else localStorage.removeItem(segmentsCountKey); + const count = toNumber(this.storage.getItem(segmentsCountKey)) + diff; + if (count > 0) this.storage.setItem(segmentsCountKey, count + ''); + else this.storage.removeItem(segmentsCountKey); } private add(rbSegment: IRBSegment): boolean { try { const name = rbSegment.name; const rbSegmentKey = this.keys.buildRBSegmentKey(name); - const rbSegmentFromLocalStorage = localStorage.getItem(rbSegmentKey); - const previous = rbSegmentFromLocalStorage ? JSON.parse(rbSegmentFromLocalStorage) : null; + const rbSegmentFromStorage = this.storage.getItem(rbSegmentKey); + const previous = rbSegmentFromStorage ? JSON.parse(rbSegmentFromStorage) : null; - localStorage.setItem(rbSegmentKey, JSON.stringify(rbSegment)); + this.storage.setItem(rbSegmentKey, JSON.stringify(rbSegment)); let usesSegmentsDiff = 0; if (previous && usesSegments(previous)) usesSegmentsDiff--; @@ -72,7 +73,7 @@ export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync { const rbSegment = this.get(name); if (!rbSegment) return false; - localStorage.removeItem(this.keys.buildRBSegmentKey(name)); + this.storage.removeItem(this.keys.buildRBSegmentKey(name)); if (usesSegments(rbSegment)) this.updateSegmentCount(-1); @@ -84,13 +85,13 @@ export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync { } private getNames(): string[] { - const len = localStorage.length; + const len = this.storage.length; const accum = []; let cur = 0; while (cur < len) { - const key = localStorage.key(cur); + const key = this.storage.key(cur); if (key != null && this.keys.isRBSegmentKey(key)) accum.push(this.keys.extractKey(key)); @@ -101,7 +102,7 @@ export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync { } get(name: string): IRBSegment | null { - const item = localStorage.getItem(this.keys.buildRBSegmentKey(name)); + const item = this.storage.getItem(this.keys.buildRBSegmentKey(name)); return item && JSON.parse(item); } @@ -117,7 +118,7 @@ export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync { getChangeNumber(): number { const n = -1; - let value: string | number | null = localStorage.getItem(this.keys.buildRBSegmentsTillKey()); + let value: string | number | null = this.storage.getItem(this.keys.buildRBSegmentsTillKey()); if (value !== null) { value = parseInt(value, 10); @@ -129,7 +130,7 @@ export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync { } usesSegments(): boolean { - const storedCount = localStorage.getItem(this.keys.buildSplitsWithSegmentCountKey()); + const storedCount = this.storage.getItem(this.keys.buildSplitsWithSegmentCountKey()); const splitsWithSegmentsCount = storedCount === null ? 0 : toNumber(storedCount); return isFiniteNumber(splitsWithSegmentsCount) ? diff --git a/src/storages/inLocalStorage/SplitsCacheInLocal.ts b/src/storages/inLocalStorage/SplitsCacheInLocal.ts index 2fb6183c..3aa08452 100644 --- a/src/storages/inLocalStorage/SplitsCacheInLocal.ts +++ b/src/storages/inLocalStorage/SplitsCacheInLocal.ts @@ -6,29 +6,28 @@ import { ILogger } from '../../logger/types'; import { LOG_PREFIX } from './constants'; import { ISettings } from '../../types'; import { setToArray } from '../../utils/lang/sets'; +import { StorageAdapter } from '../types'; -/** - * ISplitsCacheSync implementation that stores split definitions in browser LocalStorage. - */ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { private readonly keys: KeyBuilderCS; private readonly log: ILogger; private readonly flagSetsFilter: string[]; private hasSync?: boolean; + private readonly storage: StorageAdapter; - constructor(settings: ISettings, keys: KeyBuilderCS) { + constructor(settings: ISettings, keys: KeyBuilderCS, storage: StorageAdapter) { super(); this.keys = keys; this.log = settings.log; this.flagSetsFilter = settings.sync.__splitFiltersValidation.groupedFilters.bySet; + this.storage = storage; } private _decrementCount(key: string) { - const count = toNumber(localStorage.getItem(key)) - 1; - // @ts-expect-error - if (count > 0) localStorage.setItem(key, count); - else localStorage.removeItem(key); + const count = toNumber(this.storage.getItem(key)) - 1; + if (count > 0) this.storage.setItem(key, count + ''); + else this.storage.removeItem(key); } private _decrementCounts(split: ISplit) { @@ -48,13 +47,11 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { private _incrementCounts(split: ISplit) { try { const ttKey = this.keys.buildTrafficTypeKey(split.trafficTypeName); - // @ts-expect-error - localStorage.setItem(ttKey, toNumber(localStorage.getItem(ttKey)) + 1); + this.storage.setItem(ttKey, (toNumber(this.storage.getItem(ttKey)) + 1) + ''); if (usesSegments(split)) { const segmentsCountKey = this.keys.buildSplitsWithSegmentCountKey(); - // @ts-expect-error - localStorage.setItem(segmentsCountKey, toNumber(localStorage.getItem(segmentsCountKey)) + 1); + this.storage.setItem(segmentsCountKey, (toNumber(this.storage.getItem(segmentsCountKey)) + 1) + ''); } } catch (e) { this.log.error(LOG_PREFIX + e); @@ -68,15 +65,15 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { */ clear() { // collect item keys - const len = localStorage.length; + const len = this.storage.length; const accum = []; for (let cur = 0; cur < len; cur++) { - const key = localStorage.key(cur); + const key = this.storage.key(cur); if (key != null && this.keys.isSplitsCacheKey(key)) accum.push(key); } // remove items accum.forEach(key => { - localStorage.removeItem(key); + this.storage.removeItem(key); }); this.hasSync = false; @@ -86,15 +83,15 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { try { const name = split.name; const splitKey = this.keys.buildSplitKey(name); - const splitFromLocalStorage = localStorage.getItem(splitKey); - const previousSplit = splitFromLocalStorage ? JSON.parse(splitFromLocalStorage) : null; + const splitFromStorage = this.storage.getItem(splitKey); + const previousSplit = splitFromStorage ? JSON.parse(splitFromStorage) : null; if (previousSplit) { this._decrementCounts(previousSplit); this.removeFromFlagSets(previousSplit.name, previousSplit.sets); } - localStorage.setItem(splitKey, JSON.stringify(split)); + this.storage.setItem(splitKey, JSON.stringify(split)); this._incrementCounts(split); this.addToFlagSets(split); @@ -111,7 +108,7 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { const split = this.getSplit(name); if (!split) return false; - localStorage.removeItem(this.keys.buildSplitKey(name)); + this.storage.removeItem(this.keys.buildSplitKey(name)); this._decrementCounts(split); this.removeFromFlagSets(split.name, split.sets); @@ -124,15 +121,15 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { } getSplit(name: string): ISplit | null { - const item = localStorage.getItem(this.keys.buildSplitKey(name)); + const item = this.storage.getItem(this.keys.buildSplitKey(name)); return item && JSON.parse(item); } setChangeNumber(changeNumber: number): boolean { try { - localStorage.setItem(this.keys.buildSplitsTillKey(), changeNumber + ''); + this.storage.setItem(this.keys.buildSplitsTillKey(), changeNumber + ''); // update "last updated" timestamp with current time - localStorage.setItem(this.keys.buildLastUpdatedKey(), Date.now() + ''); + this.storage.setItem(this.keys.buildLastUpdatedKey(), Date.now() + ''); this.hasSync = true; return true; } catch (e) { @@ -143,7 +140,7 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { getChangeNumber(): number { const n = -1; - let value: string | number | null = localStorage.getItem(this.keys.buildSplitsTillKey()); + let value: string | number | null = this.storage.getItem(this.keys.buildSplitsTillKey()); if (value !== null) { value = parseInt(value, 10); @@ -155,13 +152,13 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { } getSplitNames(): string[] { - const len = localStorage.length; + const len = this.storage.length; const accum = []; let cur = 0; while (cur < len) { - const key = localStorage.key(cur); + const key = this.storage.key(cur); if (key != null && this.keys.isSplitKey(key)) accum.push(this.keys.extractKey(key)); @@ -172,7 +169,7 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { } trafficTypeExists(trafficType: string): boolean { - const ttCount = toNumber(localStorage.getItem(this.keys.buildTrafficTypeKey(trafficType))); + const ttCount = toNumber(this.storage.getItem(this.keys.buildTrafficTypeKey(trafficType))); return isFiniteNumber(ttCount) && ttCount > 0; } @@ -180,7 +177,7 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { // If cache hasn't been synchronized with the cloud, assume we need them. if (!this.hasSync) return true; - const storedCount = localStorage.getItem(this.keys.buildSplitsWithSegmentCountKey()); + const storedCount = this.storage.getItem(this.keys.buildSplitsWithSegmentCountKey()); const splitsWithSegmentsCount = storedCount === null ? 0 : toNumber(storedCount); return isFiniteNumber(splitsWithSegmentsCount) ? @@ -191,9 +188,9 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { getNamesByFlagSets(flagSets: string[]): Set[] { return flagSets.map(flagSet => { const flagSetKey = this.keys.buildFlagSetKey(flagSet); - const flagSetFromLocalStorage = localStorage.getItem(flagSetKey); + const flagSetFromStorage = this.storage.getItem(flagSetKey); - return new Set(flagSetFromLocalStorage ? JSON.parse(flagSetFromLocalStorage) : []); + return new Set(flagSetFromStorage ? JSON.parse(flagSetFromStorage) : []); }); } @@ -206,12 +203,12 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { const flagSetKey = this.keys.buildFlagSetKey(featureFlagSet); - const flagSetFromLocalStorage = localStorage.getItem(flagSetKey); + const flagSetFromStorage = this.storage.getItem(flagSetKey); - const flagSetCache = new Set(flagSetFromLocalStorage ? JSON.parse(flagSetFromLocalStorage) : []); + const flagSetCache = new Set(flagSetFromStorage ? JSON.parse(flagSetFromStorage) : []); flagSetCache.add(featureFlag.name); - localStorage.setItem(flagSetKey, JSON.stringify(setToArray(flagSetCache))); + this.storage.setItem(flagSetKey, JSON.stringify(setToArray(flagSetCache))); }); } @@ -226,19 +223,19 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { private removeNames(flagSetName: string, featureFlagName: string) { const flagSetKey = this.keys.buildFlagSetKey(flagSetName); - const flagSetFromLocalStorage = localStorage.getItem(flagSetKey); + const flagSetFromStorage = this.storage.getItem(flagSetKey); - if (!flagSetFromLocalStorage) return; + if (!flagSetFromStorage) return; - const flagSetCache = new Set(JSON.parse(flagSetFromLocalStorage)); + const flagSetCache = new Set(JSON.parse(flagSetFromStorage)); flagSetCache.delete(featureFlagName); if (flagSetCache.size === 0) { - localStorage.removeItem(flagSetKey); + this.storage.removeItem(flagSetKey); return; } - localStorage.setItem(flagSetKey, JSON.stringify(setToArray(flagSetCache))); + this.storage.setItem(flagSetKey, JSON.stringify(setToArray(flagSetCache))); } } diff --git a/src/storages/inLocalStorage/__tests__/MySegmentsCacheInLocal.spec.ts b/src/storages/inLocalStorage/__tests__/MySegmentsCacheInLocal.spec.ts index bb38fe10..aa218423 100644 --- a/src/storages/inLocalStorage/__tests__/MySegmentsCacheInLocal.spec.ts +++ b/src/storages/inLocalStorage/__tests__/MySegmentsCacheInLocal.spec.ts @@ -1,11 +1,13 @@ import { MySegmentsCacheInLocal } from '../MySegmentsCacheInLocal'; import { KeyBuilderCS, myLargeSegmentsKeyBuilder } from '../../KeyBuilderCS'; import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock'; +import { storages, PREFIX } from './wrapper.mock'; +import { IMySegmentsResponse } from '../../../dtos/types'; -test('SEGMENT CACHE / in LocalStorage', () => { +test.each(storages)('SEGMENT CACHE / in LocalStorage', (storage) => { const caches = [ - new MySegmentsCacheInLocal(loggerMock, new KeyBuilderCS('SPLITIO', 'user')), - new MySegmentsCacheInLocal(loggerMock, myLargeSegmentsKeyBuilder('SPLITIO', 'user')) + new MySegmentsCacheInLocal(loggerMock, new KeyBuilderCS(PREFIX, 'user'), storage), + new MySegmentsCacheInLocal(loggerMock, myLargeSegmentsKeyBuilder(PREFIX, 'user'), storage) ]; caches.forEach(cache => { @@ -22,19 +24,18 @@ test('SEGMENT CACHE / in LocalStorage', () => { }); caches.forEach(cache => { - // @ts-expect-error cache.resetSegments({ added: [], removed: ['mocked-segment'] - }); + } as IMySegmentsResponse); expect(cache.isInSegment('mocked-segment')).toBe(false); expect(cache.getRegisteredSegments()).toEqual(['mocked-segment-2']); expect(cache.getKeysCount()).toBe(1); }); - expect(localStorage.getItem('SPLITIO.user.segment.mocked-segment-2')).toBe('1'); - expect(localStorage.getItem('SPLITIO.user.segment.mocked-segment')).toBe(null); - expect(localStorage.getItem('SPLITIO.user.largeSegment.mocked-segment-2')).toBe('1'); - expect(localStorage.getItem('SPLITIO.user.largeSegment.mocked-segment')).toBe(null); + expect(storage.getItem(PREFIX + '.user.segment.mocked-segment-2')).toBe('1'); + expect(storage.getItem(PREFIX + '.user.segment.mocked-segment')).toBe(null); + expect(storage.getItem(PREFIX + '.user.largeSegment.mocked-segment-2')).toBe('1'); + expect(storage.getItem(PREFIX + '.user.largeSegment.mocked-segment')).toBe(null); }); diff --git a/src/storages/inLocalStorage/__tests__/SplitsCacheInLocal.spec.ts b/src/storages/inLocalStorage/__tests__/SplitsCacheInLocal.spec.ts index 13ab1b32..976baa72 100644 --- a/src/storages/inLocalStorage/__tests__/SplitsCacheInLocal.spec.ts +++ b/src/storages/inLocalStorage/__tests__/SplitsCacheInLocal.spec.ts @@ -3,228 +3,231 @@ import { KeyBuilderCS } from '../../KeyBuilderCS'; import { splitWithUserTT, splitWithAccountTT, splitWithAccountTTAndUsesSegments, something, somethingElse, featureFlagOne, featureFlagTwo, featureFlagThree, featureFlagWithEmptyFS, featureFlagWithoutFS } from '../../__tests__/testUtils'; import { ISplit } from '../../../dtos/types'; import { fullSettings } from '../../../utils/settingsValidation/__tests__/settings.mocks'; +import { storages, PREFIX } from './wrapper.mock'; -test('SPLITS CACHE / LocalStorage', () => { - const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS('SPLITIO', 'user')); +describe.each(storages)('SPLITS CACHE', (storage) => { + test('LocalStorage', () => { + const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS(PREFIX, 'user'), storage); - cache.clear(); + cache.clear(); - cache.update([something, somethingElse], [], -1); + cache.update([something, somethingElse], [], -1); - let values = cache.getAll(); + let values = cache.getAll(); - expect(values).toEqual([something, somethingElse]); + expect(values).toEqual([something, somethingElse]); - cache.removeSplit(something.name); + cache.removeSplit(something.name); - const splits = cache.getSplits([something.name, somethingElse.name]); - expect(splits[something.name]).toEqual(null); - expect(splits[somethingElse.name]).toEqual(somethingElse); + const splits = cache.getSplits([something.name, somethingElse.name]); + expect(splits[something.name]).toEqual(null); + expect(splits[somethingElse.name]).toEqual(somethingElse); - values = cache.getAll(); + values = cache.getAll(); - expect(values).toEqual([somethingElse]); + expect(values).toEqual([somethingElse]); - expect(cache.getSplit(something.name)).toEqual(null); - expect(cache.getSplit(somethingElse.name)).toEqual(somethingElse); + expect(cache.getSplit(something.name)).toEqual(null); + expect(cache.getSplit(somethingElse.name)).toEqual(somethingElse); - expect(cache.getChangeNumber()).toBe(-1); + expect(cache.getChangeNumber()).toBe(-1); - cache.setChangeNumber(123); + cache.setChangeNumber(123); - expect(cache.getChangeNumber()).toBe(123); -}); + expect(cache.getChangeNumber()).toBe(123); + }); -test('SPLITS CACHE / LocalStorage / Get Keys', () => { - const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS('SPLITIO', 'user')); + test('LocalStorage / Get Keys', () => { + const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS(PREFIX, 'user'), storage); - cache.update([something, somethingElse], [], 1); + cache.update([something, somethingElse], [], 1); - const keys = cache.getSplitNames(); + const keys = cache.getSplitNames(); - expect(keys.indexOf(something.name) !== -1).toBe(true); - expect(keys.indexOf(somethingElse.name) !== -1).toBe(true); -}); + expect(keys.indexOf(something.name) !== -1).toBe(true); + expect(keys.indexOf(somethingElse.name) !== -1).toBe(true); + }); -test('SPLITS CACHE / LocalStorage / Update Splits', () => { - const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS('SPLITIO', 'user')); + test('LocalStorage / Update Splits', () => { + const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS(PREFIX, 'user'), storage); - cache.update([something, somethingElse], [], 1); + cache.update([something, somethingElse], [], 1); - cache.update([], [something, somethingElse], 1); + cache.update([], [something, somethingElse], 1); - expect(cache.getSplit(something.name)).toBe(null); - expect(cache.getSplit(somethingElse.name)).toBe(null); -}); + expect(cache.getSplit(something.name)).toBe(null); + expect(cache.getSplit(somethingElse.name)).toBe(null); + }); -test('SPLITS CACHE / LocalStorage / trafficTypeExists and ttcache tests', () => { - const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS('SPLITIO', 'user')); + test('LocalStorage / trafficTypeExists and ttcache tests', () => { + const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS(PREFIX, 'user'), storage); - cache.update([ - { ...splitWithUserTT, name: 'split1' }, - { ...splitWithAccountTT, name: 'split2' }, - { ...splitWithUserTT, name: 'split3' }, - ], [], 1); - cache.addSplit({ ...splitWithUserTT, name: 'split4' }); + cache.update([ + { ...splitWithUserTT, name: 'split1' }, + { ...splitWithAccountTT, name: 'split2' }, + { ...splitWithUserTT, name: 'split3' }, + ], [], 1); + cache.addSplit({ ...splitWithUserTT, name: 'split4' }); - expect(cache.trafficTypeExists('user_tt')).toBe(true); - expect(cache.trafficTypeExists('account_tt')).toBe(true); - expect(cache.trafficTypeExists('not_existent_tt')).toBe(false); + expect(cache.trafficTypeExists('user_tt')).toBe(true); + expect(cache.trafficTypeExists('account_tt')).toBe(true); + expect(cache.trafficTypeExists('not_existent_tt')).toBe(false); - cache.removeSplit('split4'); + cache.removeSplit('split4'); - expect(cache.trafficTypeExists('user_tt')).toBe(true); - expect(cache.trafficTypeExists('account_tt')).toBe(true); + expect(cache.trafficTypeExists('user_tt')).toBe(true); + expect(cache.trafficTypeExists('account_tt')).toBe(true); - cache.removeSplit('split3'); - cache.removeSplit('split2'); + cache.removeSplit('split3'); + cache.removeSplit('split2'); - expect(cache.trafficTypeExists('user_tt')).toBe(true); - expect(cache.trafficTypeExists('account_tt')).toBe(false); + expect(cache.trafficTypeExists('user_tt')).toBe(true); + expect(cache.trafficTypeExists('account_tt')).toBe(false); - cache.removeSplit('split1'); + cache.removeSplit('split1'); - expect(cache.trafficTypeExists('user_tt')).toBe(false); - expect(cache.trafficTypeExists('account_tt')).toBe(false); + expect(cache.trafficTypeExists('user_tt')).toBe(false); + expect(cache.trafficTypeExists('account_tt')).toBe(false); - cache.addSplit({ ...splitWithUserTT, name: 'split1' }); - expect(cache.trafficTypeExists('user_tt')).toBe(true); + cache.addSplit({ ...splitWithUserTT, name: 'split1' }); + expect(cache.trafficTypeExists('user_tt')).toBe(true); - cache.addSplit({ ...splitWithAccountTT, name: 'split1' }); - expect(cache.trafficTypeExists('account_tt')).toBe(true); - expect(cache.trafficTypeExists('user_tt')).toBe(false); + cache.addSplit({ ...splitWithAccountTT, name: 'split1' }); + expect(cache.trafficTypeExists('account_tt')).toBe(true); + expect(cache.trafficTypeExists('user_tt')).toBe(false); -}); + }); -test('SPLITS CACHE / LocalStorage / killLocally', () => { - const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS('SPLITIO', 'user')); + test('LocalStorage / killLocally', () => { + const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS(PREFIX, 'user'), storage); - cache.addSplit(something); - cache.addSplit(somethingElse); - const initialChangeNumber = cache.getChangeNumber(); + cache.addSplit(something); + cache.addSplit(somethingElse); + const initialChangeNumber = cache.getChangeNumber(); - // kill an non-existent split - let updated = cache.killLocally('nonexistent_split', 'other_treatment', 101); - const nonexistentSplit = cache.getSplit('nonexistent_split'); + // kill an non-existent split + let updated = cache.killLocally('nonexistent_split', 'other_treatment', 101); + const nonexistentSplit = cache.getSplit('nonexistent_split'); - expect(updated).toBe(false); // killLocally resolves without update if split doesn't exist - expect(nonexistentSplit).toBe(null); // non-existent split keeps being non-existent + expect(updated).toBe(false); // killLocally resolves without update if split doesn't exist + expect(nonexistentSplit).toBe(null); // non-existent split keeps being non-existent - // kill an existent split - updated = cache.killLocally(something.name, 'some_treatment', 100); - let lol1Split = cache.getSplit(something.name) as ISplit; + // kill an existent split + updated = cache.killLocally(something.name, 'some_treatment', 100); + let lol1Split = cache.getSplit(something.name) as ISplit; - expect(updated).toBe(true); // killLocally resolves with update if split is changed - expect(lol1Split.killed).toBe(true); // existing split must be killed - expect(lol1Split.defaultTreatment).toBe('some_treatment'); // existing split must have new default treatment - expect(lol1Split.changeNumber).toBe(100); // existing split must have the given change number - expect(cache.getChangeNumber()).toBe(initialChangeNumber); // cache changeNumber is not changed + expect(updated).toBe(true); // killLocally resolves with update if split is changed + expect(lol1Split.killed).toBe(true); // existing split must be killed + expect(lol1Split.defaultTreatment).toBe('some_treatment'); // existing split must have new default treatment + expect(lol1Split.changeNumber).toBe(100); // existing split must have the given change number + expect(cache.getChangeNumber()).toBe(initialChangeNumber); // cache changeNumber is not changed - // not update if changeNumber is old - updated = cache.killLocally(something.name, 'some_treatment_2', 90); - lol1Split = cache.getSplit(something.name) as ISplit; + // not update if changeNumber is old + updated = cache.killLocally(something.name, 'some_treatment_2', 90); + lol1Split = cache.getSplit(something.name) as ISplit; - expect(updated).toBe(false); // killLocally resolves without update if changeNumber is old - expect(lol1Split.defaultTreatment).not.toBe('some_treatment_2'); // existing split is not updated if given changeNumber is older + expect(updated).toBe(false); // killLocally resolves without update if changeNumber is old + expect(lol1Split.defaultTreatment).not.toBe('some_treatment_2'); // existing split is not updated if given changeNumber is older -}); + }); -test('SPLITS CACHE / LocalStorage / usesSegments', () => { - const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS('SPLITIO', 'user')); + test('LocalStorage / usesSegments', () => { + const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS(PREFIX, 'user'), storage); - expect(cache.usesSegments()).toBe(true); // true initially, until data is synchronized - cache.setChangeNumber(1); // to indicate that data has been synced. + expect(cache.usesSegments()).toBe(true); // true initially, until data is synchronized + cache.setChangeNumber(1); // to indicate that data has been synced. - cache.update([splitWithUserTT, splitWithAccountTT], [], 1); - expect(cache.usesSegments()).toBe(false); // 0 splits using segments + cache.update([splitWithUserTT, splitWithAccountTT], [], 1); + expect(cache.usesSegments()).toBe(false); // 0 splits using segments - cache.addSplit({ ...splitWithAccountTTAndUsesSegments, name: 'split3' }); - expect(cache.usesSegments()).toBe(true); // 1 split using segments + cache.addSplit({ ...splitWithAccountTTAndUsesSegments, name: 'split3' }); + expect(cache.usesSegments()).toBe(true); // 1 split using segments - cache.addSplit({ ...splitWithAccountTTAndUsesSegments, name: 'split4' }); - expect(cache.usesSegments()).toBe(true); // 2 splits using segments + cache.addSplit({ ...splitWithAccountTTAndUsesSegments, name: 'split4' }); + expect(cache.usesSegments()).toBe(true); // 2 splits using segments - cache.removeSplit('split3'); - expect(cache.usesSegments()).toBe(true); // 1 split using segments + cache.removeSplit('split3'); + expect(cache.usesSegments()).toBe(true); // 1 split using segments - cache.removeSplit('split4'); - expect(cache.usesSegments()).toBe(false); // 0 splits using segments -}); + cache.removeSplit('split4'); + expect(cache.usesSegments()).toBe(false); // 0 splits using segments + }); -test('SPLITS CACHE / LocalStorage / flag set cache tests', () => { - // @ts-ignore - const cache = new SplitsCacheInLocal({ - ...fullSettings, - sync: { // @ts-expect-error - __splitFiltersValidation: { - groupedFilters: { bySet: ['o', 'n', 'e', 'x'], byName: [], byPrefix: [] }, - queryString: '&sets=e,n,o,x', + test('LocalStorage / flag set cache tests', () => { + // @ts-ignore + const cache = new SplitsCacheInLocal({ + ...fullSettings, + sync: { // @ts-expect-error + __splitFiltersValidation: { + groupedFilters: { bySet: ['o', 'n', 'e', 'x'], byName: [], byPrefix: [] }, + queryString: '&sets=e,n,o,x', + } } - } - }, new KeyBuilderCS('SPLITIO', 'user')); + }, new KeyBuilderCS(PREFIX, 'user'), storage); - const emptySet = new Set([]); + const emptySet = new Set([]); - cache.update([ - featureFlagOne, - featureFlagTwo, - featureFlagThree, - ], [], -1); - cache.addSplit(featureFlagWithEmptyFS); + cache.update([ + featureFlagOne, + featureFlagTwo, + featureFlagThree, + ], [], -1); + cache.addSplit(featureFlagWithEmptyFS); - // Adding an existing FF should not affect the cache - cache.update([featureFlagTwo], [], -1); + // Adding an existing FF should not affect the cache + cache.update([featureFlagTwo], [], -1); - expect(cache.getNamesByFlagSets(['o'])).toEqual([new Set(['ff_one', 'ff_two'])]); - expect(cache.getNamesByFlagSets(['n'])).toEqual([new Set(['ff_one'])]); - expect(cache.getNamesByFlagSets(['e'])).toEqual([new Set(['ff_one', 'ff_three'])]); - expect(cache.getNamesByFlagSets(['t'])).toEqual([emptySet]); // 't' not in filter - expect(cache.getNamesByFlagSets(['o', 'n', 'e'])).toEqual([new Set(['ff_one', 'ff_two']), new Set(['ff_one']), new Set(['ff_one', 'ff_three'])]); + expect(cache.getNamesByFlagSets(['o'])).toEqual([new Set(['ff_one', 'ff_two'])]); + expect(cache.getNamesByFlagSets(['n'])).toEqual([new Set(['ff_one'])]); + expect(cache.getNamesByFlagSets(['e'])).toEqual([new Set(['ff_one', 'ff_three'])]); + expect(cache.getNamesByFlagSets(['t'])).toEqual([emptySet]); // 't' not in filter + expect(cache.getNamesByFlagSets(['o', 'n', 'e'])).toEqual([new Set(['ff_one', 'ff_two']), new Set(['ff_one']), new Set(['ff_one', 'ff_three'])]); - cache.addSplit({ ...featureFlagOne, sets: ['1'] }); + cache.addSplit({ ...featureFlagOne, sets: ['1'] }); - expect(cache.getNamesByFlagSets(['1'])).toEqual([emptySet]); // '1' not in filter - expect(cache.getNamesByFlagSets(['o'])).toEqual([new Set(['ff_two'])]); - expect(cache.getNamesByFlagSets(['n'])).toEqual([emptySet]); + expect(cache.getNamesByFlagSets(['1'])).toEqual([emptySet]); // '1' not in filter + expect(cache.getNamesByFlagSets(['o'])).toEqual([new Set(['ff_two'])]); + expect(cache.getNamesByFlagSets(['n'])).toEqual([emptySet]); - cache.addSplit({ ...featureFlagOne, sets: ['x'] }); - expect(cache.getNamesByFlagSets(['x'])).toEqual([new Set(['ff_one'])]); - expect(cache.getNamesByFlagSets(['o', 'e', 'x'])).toEqual([new Set(['ff_two']), new Set(['ff_three']), new Set(['ff_one'])]); + cache.addSplit({ ...featureFlagOne, sets: ['x'] }); + expect(cache.getNamesByFlagSets(['x'])).toEqual([new Set(['ff_one'])]); + expect(cache.getNamesByFlagSets(['o', 'e', 'x'])).toEqual([new Set(['ff_two']), new Set(['ff_three']), new Set(['ff_one'])]); - cache.removeSplit(featureFlagOne.name); - expect(cache.getNamesByFlagSets(['x'])).toEqual([emptySet]); + cache.removeSplit(featureFlagOne.name); + expect(cache.getNamesByFlagSets(['x'])).toEqual([emptySet]); - cache.removeSplit(featureFlagOne.name); - expect(cache.getNamesByFlagSets(['y'])).toEqual([emptySet]); // 'y' not in filter - expect(cache.getNamesByFlagSets([])).toEqual([]); + cache.removeSplit(featureFlagOne.name); + expect(cache.getNamesByFlagSets(['y'])).toEqual([emptySet]); // 'y' not in filter + expect(cache.getNamesByFlagSets([])).toEqual([]); - cache.addSplit(featureFlagWithoutFS); - expect(cache.getNamesByFlagSets([])).toEqual([]); -}); + cache.addSplit(featureFlagWithoutFS); + expect(cache.getNamesByFlagSets([])).toEqual([]); + }); + + // if FlagSets are not defined, it should store all FlagSets in memory. + test('LocalStorage / flag set cache tests without filters', () => { + const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS(PREFIX, 'user'), storage); + + const emptySet = new Set([]); + + cache.update([ + featureFlagOne, + featureFlagTwo, + featureFlagThree, + ], [], -1); + cache.addSplit(featureFlagWithEmptyFS); + + expect(cache.getNamesByFlagSets(['o'])).toEqual([new Set(['ff_one', 'ff_two'])]); + expect(cache.getNamesByFlagSets(['n'])).toEqual([new Set(['ff_one'])]); + expect(cache.getNamesByFlagSets(['e'])).toEqual([new Set(['ff_one', 'ff_three'])]); + expect(cache.getNamesByFlagSets(['t'])).toEqual([new Set(['ff_two', 'ff_three'])]); + expect(cache.getNamesByFlagSets(['y'])).toEqual([emptySet]); + expect(cache.getNamesByFlagSets(['o', 'n', 'e'])).toEqual([new Set(['ff_one', 'ff_two']), new Set(['ff_one']), new Set(['ff_one', 'ff_three'])]); -// if FlagSets are not defined, it should store all FlagSets in memory. -test('SPLIT CACHE / LocalStorage / flag set cache tests without filters', () => { - const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS('SPLITIO', 'user')); - - const emptySet = new Set([]); - - cache.update([ - featureFlagOne, - featureFlagTwo, - featureFlagThree, - ], [], -1); - cache.addSplit(featureFlagWithEmptyFS); - - expect(cache.getNamesByFlagSets(['o'])).toEqual([new Set(['ff_one', 'ff_two'])]); - expect(cache.getNamesByFlagSets(['n'])).toEqual([new Set(['ff_one'])]); - expect(cache.getNamesByFlagSets(['e'])).toEqual([new Set(['ff_one', 'ff_three'])]); - expect(cache.getNamesByFlagSets(['t'])).toEqual([new Set(['ff_two', 'ff_three'])]); - expect(cache.getNamesByFlagSets(['y'])).toEqual([emptySet]); - expect(cache.getNamesByFlagSets(['o', 'n', 'e'])).toEqual([new Set(['ff_one', 'ff_two']), new Set(['ff_one']), new Set(['ff_one', 'ff_three'])]); - - // Validate that the feature flag cache is cleared when calling `clear` method - cache.clear(); - expect(localStorage.length).toBe(0); + // Validate that the feature flag cache is cleared when calling `clear` method + cache.clear(); + expect(storage.length).toBe(0); + }); }); diff --git a/src/storages/inLocalStorage/__tests__/index.spec.ts b/src/storages/inLocalStorage/__tests__/index.spec.ts index b9d0fc1d..3341871d 100644 --- a/src/storages/inLocalStorage/__tests__/index.spec.ts +++ b/src/storages/inLocalStorage/__tests__/index.spec.ts @@ -10,6 +10,10 @@ jest.mock('../../inMemory/InMemoryStorageCS', () => { import { IStorageFactoryParams } from '../../types'; import { assertStorageInterface } from '../../__tests__/testUtils'; import { fullSettings } from '../../../utils/settingsValidation/__tests__/settings.mocks'; +import { createMemoryStorage } from './wrapper.mock'; +import * as storageAdapter from '../storageAdapter'; + +const storageAdapterSpy = jest.spyOn(storageAdapter, 'storageAdapter'); // Test target import { InLocalStorage } from '../index'; @@ -23,29 +27,63 @@ describe('IN LOCAL STORAGE', () => { fakeInMemoryStorageFactory.mockClear(); }); - test('calls InMemoryStorage factory if LocalStorage API is not available', () => { - + test('fallback to InMemoryStorage if LocalStorage API is not available or the provided storage wrapper is invalid', () => { + // Delete global localStorage property const originalLocalStorage = Object.getOwnPropertyDescriptor(global, 'localStorage'); - Object.defineProperty(global, 'localStorage', {}); // delete global localStorage property - - const storageFactory = InLocalStorage({ prefix: 'prefix' }); - const storage = storageFactory(internalSdkParams); + Object.defineProperty(global, 'localStorage', {}); + // LocalStorage API is not available + let storageFactory = InLocalStorage({ prefix: 'prefix' }); + let storage = storageFactory(internalSdkParams); expect(fakeInMemoryStorageFactory).toBeCalledWith(internalSdkParams); // calls InMemoryStorage factory expect(storage).toBe(fakeInMemoryStorage); - Object.defineProperty(global, 'localStorage', originalLocalStorage as PropertyDescriptor); // restore original localStorage + // @ts-expect-error Provided storage is invalid + storageFactory = InLocalStorage({ prefix: 'prefix', wrapper: {} }); + storage = storageFactory(internalSdkParams); + expect(storage).toBe(fakeInMemoryStorage); + + // Provided storage is valid + storageFactory = InLocalStorage({ prefix: 'prefix', wrapper: createMemoryStorage() }); + storage = storageFactory(internalSdkParams); + expect(storage).not.toBe(fakeInMemoryStorage); + // Restore original localStorage + Object.defineProperty(global, 'localStorage', originalLocalStorage as PropertyDescriptor); }); - test('calls its own storage factory if LocalStorage API is available', () => { + test('calls InLocalStorage if LocalStorage API is available', () => { const storageFactory = InLocalStorage({ prefix: 'prefix' }); const storage = storageFactory(internalSdkParams); assertStorageInterface(storage); // the instance must implement the storage interface expect(fakeInMemoryStorageFactory).not.toBeCalled(); // doesn't call InMemoryStorage factory + }); + test('calls InLocalStorage if the provided storage wrapper is valid', () => { + storageAdapterSpy.mockClear(); + + // Web Storages should not use the storageAdapter + let storageFactory = InLocalStorage({ prefix: 'prefix', wrapper: localStorage }); + let storage = storageFactory(internalSdkParams); + assertStorageInterface(storage); + expect(fakeInMemoryStorageFactory).not.toBeCalled(); + expect(storageAdapterSpy).not.toBeCalled(); + + storageFactory = InLocalStorage({ prefix: 'prefix', wrapper: sessionStorage }); + storage = storageFactory(internalSdkParams); + assertStorageInterface(storage); + expect(fakeInMemoryStorageFactory).not.toBeCalled(); + expect(storageAdapterSpy).not.toBeCalled(); + + // Non Web Storages should use the storageAdapter + storageFactory = InLocalStorage({ prefix: 'prefix', wrapper: createMemoryStorage() }); + storage = storageFactory(internalSdkParams); + + assertStorageInterface(storage); + expect(fakeInMemoryStorageFactory).not.toBeCalled(); + expect(storageAdapterSpy).toBeCalled(); }); }); diff --git a/src/storages/inLocalStorage/__tests__/storageAdapter.spec.ts b/src/storages/inLocalStorage/__tests__/storageAdapter.spec.ts new file mode 100644 index 00000000..84be924a --- /dev/null +++ b/src/storages/inLocalStorage/__tests__/storageAdapter.spec.ts @@ -0,0 +1,62 @@ +import { storageAdapter } from '../storageAdapter'; +import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock'; + + +const syncWrapper = { + getItem: jest.fn(() => JSON.stringify({ key1: 'value1' })), + setItem: jest.fn(), + removeItem: jest.fn(), +}; + +const asyncWrapper = { + getItem: jest.fn(() => Promise.resolve(JSON.stringify({ key1: 'value1' }))), + setItem: jest.fn(() => Promise.resolve()), + removeItem: jest.fn(() => Promise.resolve()), +}; + +test.each([ + [syncWrapper], + [asyncWrapper], +])('storageAdapter', async (wrapper) => { + + const storage = storageAdapter(loggerMock, 'prefix', wrapper); + + expect(storage.length).toBe(0); + + // Load cache from storage wrapper + await storage.load(); + + expect(wrapper.getItem).toHaveBeenCalledWith('prefix'); + expect(storage.length).toBe(1); + expect(storage.key(0)).toBe('key1'); + expect(storage.getItem('key1')).toBe('value1'); + + // Set item + storage.setItem('key2', 'value2'); + expect(storage.getItem('key2')).toBe('value2'); + expect(storage.length).toBe(2); + + // Remove item + storage.removeItem('key1'); + expect(storage.getItem('key1')).toBe(null); + expect(storage.length).toBe(1); + + // Until `save` is called, changes should not be saved/persisted + await storage.whenSaved(); + expect(wrapper.setItem).not.toHaveBeenCalled(); + + storage.setItem('.till', '1'); + expect(storage.length).toBe(2); + expect(storage.key(0)).toBe('key2'); + expect(storage.key(1)).toBe('.till'); + + // When `save` is called, changes should be saved/persisted immediately + storage.save(); + await storage.whenSaved(); + expect(wrapper.setItem).toHaveBeenCalledWith('prefix', JSON.stringify({ key2: 'value2', '.till': '1' })); + + expect(wrapper.setItem).toHaveBeenCalledTimes(1); + + await storage.whenSaved(); + expect(wrapper.setItem).toHaveBeenCalledTimes(1); +}); diff --git a/src/storages/inLocalStorage/__tests__/validateCache.spec.ts b/src/storages/inLocalStorage/__tests__/validateCache.spec.ts index b87fa67b..102444ba 100644 --- a/src/storages/inLocalStorage/__tests__/validateCache.spec.ts +++ b/src/storages/inLocalStorage/__tests__/validateCache.spec.ts @@ -6,16 +6,17 @@ import { SplitsCacheInLocal } from '../SplitsCacheInLocal'; import { nearlyEqual } from '../../../__tests__/testUtils'; import { MySegmentsCacheInLocal } from '../MySegmentsCacheInLocal'; import { RBSegmentsCacheInLocal } from '../RBSegmentsCacheInLocal'; +import { storages, PREFIX } from './wrapper.mock'; const FULL_SETTINGS_HASH = 'dc1f9817'; -describe('validateCache', () => { - const keys = new KeyBuilderCS('SPLITIO', 'user'); +describe.each(storages)('validateCache', (storage) => { + const keys = new KeyBuilderCS(PREFIX, 'user'); const logSpy = jest.spyOn(fullSettings.log, 'info'); - const segments = new MySegmentsCacheInLocal(fullSettings.log, keys); - const largeSegments = new MySegmentsCacheInLocal(fullSettings.log, keys); - const splits = new SplitsCacheInLocal(fullSettings, keys); - const rbSegments = new RBSegmentsCacheInLocal(fullSettings, keys); + const segments = new MySegmentsCacheInLocal(fullSettings.log, keys, storage); + const largeSegments = new MySegmentsCacheInLocal(fullSettings.log, keys, storage); + const splits = new SplitsCacheInLocal(fullSettings, keys, storage); + const rbSegments = new RBSegmentsCacheInLocal(fullSettings, keys, storage); jest.spyOn(splits, 'getChangeNumber'); jest.spyOn(splits, 'clear'); @@ -25,11 +26,11 @@ describe('validateCache', () => { beforeEach(() => { jest.clearAllMocks(); - localStorage.clear(); + for (let i = 0; i < storage.length; i++) storage.removeItem(storage.key(i) as string); }); - test('if there is no cache, it should return false', () => { - expect(validateCache({}, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); + test('if there is no cache, it should return false', async () => { + expect(await validateCache({}, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); expect(logSpy).not.toHaveBeenCalled(); @@ -39,15 +40,15 @@ describe('validateCache', () => { expect(largeSegments.clear).not.toHaveBeenCalled(); expect(splits.getChangeNumber).toHaveBeenCalledTimes(1); - expect(localStorage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH); - expect(localStorage.getItem(keys.buildLastClear())).toBeNull(); + expect(storage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH); + expect(storage.getItem(keys.buildLastClear())).toBeNull(); }); - test('if there is cache and it must not be cleared, it should return true', () => { - localStorage.setItem(keys.buildSplitsTillKey(), '1'); - localStorage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH); + test('if there is cache and it must not be cleared, it should return true', async () => { + storage.setItem(keys.buildSplitsTillKey(), '1'); + storage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH); - expect(validateCache({}, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(true); + expect(await validateCache({}, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(true); expect(logSpy).not.toHaveBeenCalled(); @@ -57,16 +58,16 @@ describe('validateCache', () => { expect(largeSegments.clear).not.toHaveBeenCalled(); expect(splits.getChangeNumber).toHaveBeenCalledTimes(1); - expect(localStorage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH); - expect(localStorage.getItem(keys.buildLastClear())).toBeNull(); + expect(storage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH); + expect(storage.getItem(keys.buildLastClear())).toBeNull(); }); - test('if there is cache and it has expired, it should clear cache and return false', () => { - localStorage.setItem(keys.buildSplitsTillKey(), '1'); - localStorage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH); - localStorage.setItem(keys.buildLastUpdatedKey(), Date.now() - 1000 * 60 * 60 * 24 * 2 + ''); // 2 days ago + test('if there is cache and it has expired, it should clear cache and return false', async () => { + storage.setItem(keys.buildSplitsTillKey(), '1'); + storage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH); + storage.setItem(keys.buildLastUpdatedKey(), Date.now() - 1000 * 60 * 60 * 24 * 2 + ''); // 2 days ago - expect(validateCache({ expirationDays: 1 }, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); + expect(await validateCache({ expirationDays: 1 }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); expect(logSpy).toHaveBeenCalledWith('storage:localstorage: Cache expired more than 1 days ago. Cleaning up cache'); @@ -75,15 +76,15 @@ describe('validateCache', () => { expect(segments.clear).toHaveBeenCalledTimes(1); expect(largeSegments.clear).toHaveBeenCalledTimes(1); - expect(localStorage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH); - expect(nearlyEqual(parseInt(localStorage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true); + expect(storage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH); + expect(nearlyEqual(parseInt(storage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true); }); - test('if there is cache and its hash has changed, it should clear cache and return false', () => { - localStorage.setItem(keys.buildSplitsTillKey(), '1'); - localStorage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH); + test('if there is cache and its hash has changed, it should clear cache and return false', async () => { + storage.setItem(keys.buildSplitsTillKey(), '1'); + storage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH); - expect(validateCache({}, { ...fullSettings, core: { ...fullSettings.core, authorizationKey: 'another-sdk-key' } }, keys, splits, rbSegments, segments, largeSegments)).toBe(false); + expect(await validateCache({}, storage, { ...fullSettings, core: { ...fullSettings.core, authorizationKey: 'another-sdk-key' } }, keys, splits, rbSegments, segments, largeSegments)).toBe(false); expect(logSpy).toHaveBeenCalledWith('storage:localstorage: SDK key, flags filter criteria, or flags spec version has changed. Cleaning up cache'); @@ -92,16 +93,16 @@ describe('validateCache', () => { expect(segments.clear).toHaveBeenCalledTimes(1); expect(largeSegments.clear).toHaveBeenCalledTimes(1); - expect(localStorage.getItem(keys.buildHashKey())).toBe('45c6ba5d'); - expect(nearlyEqual(parseInt(localStorage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true); + expect(storage.getItem(keys.buildHashKey())).toBe('45c6ba5d'); + expect(nearlyEqual(parseInt(storage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true); }); - test('if there is cache and clearOnInit is true, it should clear cache and return false', () => { + test('if there is cache and clearOnInit is true, it should clear cache and return false', async () => { // Older cache version (without last clear) - localStorage.setItem(keys.buildSplitsTillKey(), '1'); - localStorage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH); + storage.setItem(keys.buildSplitsTillKey(), '1'); + storage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH); - expect(validateCache({ clearOnInit: true }, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); + expect(await validateCache({ clearOnInit: true }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); expect(logSpy).toHaveBeenCalledWith('storage:localstorage: clearOnInit was set and cache was not cleared in the last 24 hours. Cleaning up cache'); @@ -110,25 +111,25 @@ describe('validateCache', () => { expect(segments.clear).toHaveBeenCalledTimes(1); expect(largeSegments.clear).toHaveBeenCalledTimes(1); - expect(localStorage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH); - const lastClear = localStorage.getItem(keys.buildLastClear()); + expect(storage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH); + const lastClear = storage.getItem(keys.buildLastClear()); expect(nearlyEqual(parseInt(lastClear as string), Date.now())).toBe(true); // If cache is cleared, it should not clear again until a day has passed logSpy.mockClear(); - localStorage.setItem(keys.buildSplitsTillKey(), '1'); - expect(validateCache({ clearOnInit: true }, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(true); + storage.setItem(keys.buildSplitsTillKey(), '1'); + expect(await validateCache({ clearOnInit: true }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(true); expect(logSpy).not.toHaveBeenCalled(); - expect(localStorage.getItem(keys.buildLastClear())).toBe(lastClear); // Last clear should not have changed + expect(storage.getItem(keys.buildLastClear())).toBe(lastClear); // Last clear should not have changed // If a day has passed, it should clear again - localStorage.setItem(keys.buildLastClear(), (Date.now() - 1000 * 60 * 60 * 24 - 1) + ''); - expect(validateCache({ clearOnInit: true }, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); + storage.setItem(keys.buildLastClear(), (Date.now() - 1000 * 60 * 60 * 24 - 1) + ''); + expect(await validateCache({ clearOnInit: true }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); expect(logSpy).toHaveBeenCalledWith('storage:localstorage: clearOnInit was set and cache was not cleared in the last 24 hours. Cleaning up cache'); expect(splits.clear).toHaveBeenCalledTimes(2); expect(rbSegments.clear).toHaveBeenCalledTimes(2); expect(segments.clear).toHaveBeenCalledTimes(2); expect(largeSegments.clear).toHaveBeenCalledTimes(2); - expect(nearlyEqual(parseInt(localStorage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true); + expect(nearlyEqual(parseInt(storage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true); }); }); diff --git a/src/storages/inLocalStorage/__tests__/wrapper.mock.ts b/src/storages/inLocalStorage/__tests__/wrapper.mock.ts new file mode 100644 index 00000000..9cbcbf02 --- /dev/null +++ b/src/storages/inLocalStorage/__tests__/wrapper.mock.ts @@ -0,0 +1,27 @@ +import { storageAdapter } from '../storageAdapter'; +import SplitIO from '../../../../types/splitio'; +import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock'; + +export const PREFIX = 'SPLITIO'; + +export function createMemoryStorage(): SplitIO.StorageWrapper { + let cache: Record = {}; + return { + getItem(key: string) { + return Promise.resolve(cache[key] || null); + }, + setItem(key: string, value: string) { + cache[key] = value; + return Promise.resolve(); + }, + removeItem(key: string) { + delete cache[key]; + return Promise.resolve(); + } + }; +} + +export const storages = [ + localStorage, + storageAdapter(loggerMock, PREFIX, createMemoryStorage()) +]; diff --git a/src/storages/inLocalStorage/index.ts b/src/storages/inLocalStorage/index.ts index 8924b84d..fa19081e 100644 --- a/src/storages/inLocalStorage/index.ts +++ b/src/storages/inLocalStorage/index.ts @@ -1,10 +1,10 @@ import { ImpressionsCacheInMemory } from '../inMemory/ImpressionsCacheInMemory'; import { ImpressionCountsCacheInMemory } from '../inMemory/ImpressionCountsCacheInMemory'; import { EventsCacheInMemory } from '../inMemory/EventsCacheInMemory'; -import { IStorageFactoryParams, IStorageSync, IStorageSyncFactory } from '../types'; +import { IStorageFactoryParams, IStorageSync, IStorageSyncFactory, StorageAdapter } from '../types'; import { validatePrefix } from '../KeyBuilder'; import { KeyBuilderCS, myLargeSegmentsKeyBuilder } from '../KeyBuilderCS'; -import { isLocalStorageAvailable } from '../../utils/env/isLocalStorageAvailable'; +import { isLocalStorageAvailable, isValidStorageWrapper, isWebStorage } from '../../utils/env/isLocalStorageAvailable'; import { SplitsCacheInLocal } from './SplitsCacheInLocal'; import { RBSegmentsCacheInLocal } from './RBSegmentsCacheInLocal'; import { MySegmentsCacheInLocal } from './MySegmentsCacheInLocal'; @@ -15,7 +15,24 @@ import { shouldRecordTelemetry, TelemetryCacheInMemory } from '../inMemory/Telem import { UniqueKeysCacheInMemoryCS } from '../inMemory/UniqueKeysCacheInMemoryCS'; import { getMatching } from '../../utils/key'; import { validateCache } from './validateCache'; +import { ILogger } from '../../logger/types'; import SplitIO from '../../../types/splitio'; +import { storageAdapter } from './storageAdapter'; + +function validateStorage(log: ILogger, prefix: string, wrapper?: SplitIO.StorageWrapper): StorageAdapter | undefined { + if (wrapper) { + if (isValidStorageWrapper(wrapper)) { + return isWebStorage(wrapper) ? + wrapper as StorageAdapter: // localStorage and sessionStorage don't need adapter + storageAdapter(log, prefix, wrapper); + } + log.warn(LOG_PREFIX + 'Invalid storage provided. Falling back to LocalStorage API'); + } + + if (isLocalStorageAvailable()) return localStorage; + + log.warn(LOG_PREFIX + 'LocalStorage API is unavailable. Falling back to default MEMORY storage'); +} /** * InLocal storage factory for standalone client-side SplitFactory @@ -25,21 +42,19 @@ export function InLocalStorage(options: SplitIO.InLocalStorageOptions = {}): ISt const prefix = validatePrefix(options.prefix); function InLocalStorageCSFactory(params: IStorageFactoryParams): IStorageSync { + const { settings, settings: { log, scheduler: { impressionsQueueSize, eventsQueueSize } } } = params; - // Fallback to InMemoryStorage if LocalStorage API is not available - if (!isLocalStorageAvailable()) { - params.settings.log.warn(LOG_PREFIX + 'LocalStorage API is unavailable. Falling back to default MEMORY storage'); - return InMemoryStorageCSFactory(params); - } + const storage = validateStorage(log, prefix, options.wrapper); + if (!storage) return InMemoryStorageCSFactory(params); - const { settings, settings: { log, scheduler: { impressionsQueueSize, eventsQueueSize } } } = params; const matchingKey = getMatching(settings.core.key); const keys = new KeyBuilderCS(prefix, matchingKey); - const splits = new SplitsCacheInLocal(settings, keys); - const rbSegments = new RBSegmentsCacheInLocal(settings, keys); - const segments = new MySegmentsCacheInLocal(log, keys); - const largeSegments = new MySegmentsCacheInLocal(log, myLargeSegmentsKeyBuilder(prefix, matchingKey)); + const splits = new SplitsCacheInLocal(settings, keys, storage); + const rbSegments = new RBSegmentsCacheInLocal(settings, keys, storage); + const segments = new MySegmentsCacheInLocal(log, keys, storage); + const largeSegments = new MySegmentsCacheInLocal(log, myLargeSegmentsKeyBuilder(prefix, matchingKey), storage); + let validateCachePromise: Promise | undefined; return { splits, @@ -53,10 +68,16 @@ export function InLocalStorage(options: SplitIO.InLocalStorageOptions = {}): ISt uniqueKeys: new UniqueKeysCacheInMemoryCS(), validateCache() { - return validateCache(options, settings, keys, splits, rbSegments, segments, largeSegments); + return validateCachePromise || (validateCachePromise = validateCache(options, storage, settings, keys, splits, rbSegments, segments, largeSegments)); }, - destroy() { }, + save() { + return storage.save && storage.save(); + }, + + destroy() { + return storage.whenSaved && storage.whenSaved(); + }, // When using shared instantiation with MEMORY we reuse everything but segments (they are customer per key). shared(matchingKey: string) { @@ -64,8 +85,8 @@ export function InLocalStorage(options: SplitIO.InLocalStorageOptions = {}): ISt return { splits: this.splits, rbSegments: this.rbSegments, - segments: new MySegmentsCacheInLocal(log, new KeyBuilderCS(prefix, matchingKey)), - largeSegments: new MySegmentsCacheInLocal(log, myLargeSegmentsKeyBuilder(prefix, matchingKey)), + segments: new MySegmentsCacheInLocal(log, new KeyBuilderCS(prefix, matchingKey), storage), + largeSegments: new MySegmentsCacheInLocal(log, myLargeSegmentsKeyBuilder(prefix, matchingKey), storage), impressions: this.impressions, impressionCounts: this.impressionCounts, events: this.events, diff --git a/src/storages/inLocalStorage/storageAdapter.ts b/src/storages/inLocalStorage/storageAdapter.ts new file mode 100644 index 00000000..5b9db838 --- /dev/null +++ b/src/storages/inLocalStorage/storageAdapter.ts @@ -0,0 +1,62 @@ +import { ILogger } from '../../logger/types'; +import SplitIO from '../../../types/splitio'; +import { LOG_PREFIX } from './constants'; +import { StorageAdapter } from '../types'; + + +export function storageAdapter(log: ILogger, prefix: string, wrapper: SplitIO.StorageWrapper): Required { + let keys: string[] = []; + let cache: Record = {}; + + let loadPromise: Promise | undefined; + let savePromise = Promise.resolve(); + + return { + load() { + return loadPromise || (loadPromise = Promise.resolve().then(() => { + return wrapper.getItem(prefix); + }).then((storedCache) => { + cache = JSON.parse(storedCache || '{}'); + keys = Object.keys(cache); + }).catch((e) => { + log.error(LOG_PREFIX + 'Error calling wrapper `getItem` method: ' + e); + })); + }, + + save() { + return savePromise = savePromise.then(() => { + return Promise.resolve(wrapper.setItem(prefix, JSON.stringify(cache))); + }).catch((e) => { + log.error(LOG_PREFIX + 'Error calling wrapper `setItem` method: ' + e); + }); + }, + + whenSaved() { + return savePromise; + }, + + get length() { + return keys.length; + }, + + getItem(key: string) { + return cache[key] || null; + }, + + key(index: number) { + return keys[index] || null; + }, + + removeItem(key: string) { + const index = keys.indexOf(key); + if (index === -1) return; + keys.splice(index, 1); + delete cache[key]; + }, + + setItem(key: string, value: string) { + if (keys.indexOf(key) === -1) keys.push(key); + cache[key] = value; + } + }; +} diff --git a/src/storages/inLocalStorage/validateCache.ts b/src/storages/inLocalStorage/validateCache.ts index 3fa54ec6..73397075 100644 --- a/src/storages/inLocalStorage/validateCache.ts +++ b/src/storages/inLocalStorage/validateCache.ts @@ -7,6 +7,7 @@ import type { RBSegmentsCacheInLocal } from './RBSegmentsCacheInLocal'; import type { MySegmentsCacheInLocal } from './MySegmentsCacheInLocal'; import { KeyBuilderCS } from '../KeyBuilderCS'; import SplitIO from '../../../types/splitio'; +import { StorageAdapter } from '../types'; const DEFAULT_CACHE_EXPIRATION_IN_DAYS = 10; const MILLIS_IN_A_DAY = 86400000; @@ -16,11 +17,11 @@ 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) { +function validateExpiration(options: SplitIO.InLocalStorageOptions, storage: StorageAdapter, settings: ISettings, keys: KeyBuilderCS, currentTimestamp: number, isThereCache: boolean) { const { log, initialRolloutPlan } = settings; // Check expiration - const lastUpdatedTimestamp = parseInt(localStorage.getItem(keys.buildLastUpdatedKey()) as string, 10); + const lastUpdatedTimestamp = parseInt(storage.getItem(keys.buildLastUpdatedKey()) as string, 10); if (!isNaNNumber(lastUpdatedTimestamp)) { const cacheExpirationInDays = isFiniteNumber(options.expirationDays) && options.expirationDays >= 1 ? options.expirationDays : DEFAULT_CACHE_EXPIRATION_IN_DAYS; const expirationTimestamp = currentTimestamp - MILLIS_IN_A_DAY * cacheExpirationInDays; @@ -32,12 +33,12 @@ function validateExpiration(options: SplitIO.InLocalStorageOptions, settings: IS // Check hash const storageHashKey = keys.buildHashKey(); - const storageHash = localStorage.getItem(storageHashKey); + const storageHash = storage.getItem(storageHashKey); const currentStorageHash = getStorageHash(settings); if (storageHash !== currentStorageHash) { try { - localStorage.setItem(storageHashKey, currentStorageHash); + storage.setItem(storageHashKey, currentStorageHash); } catch (e) { log.error(LOG_PREFIX + e); } @@ -50,7 +51,7 @@ function validateExpiration(options: SplitIO.InLocalStorageOptions, settings: IS // Clear on init if (options.clearOnInit) { - const lastClearTimestamp = parseInt(localStorage.getItem(keys.buildLastClear()) as string, 10); + const lastClearTimestamp = parseInt(storage.getItem(keys.buildLastClear()) as string, 10); if (isNaNNumber(lastClearTimestamp) || lastClearTimestamp < currentTimestamp - MILLIS_IN_A_DAY) { log.info(LOG_PREFIX + 'clearOnInit was set and cache was not cleared in the last 24 hours. Cleaning up cache'); @@ -67,27 +68,32 @@ function validateExpiration(options: SplitIO.InLocalStorageOptions, settings: IS * * @returns `true` if cache is ready to be used, `false` otherwise (cache was cleared or there is no cache) */ -export function validateCache(options: SplitIO.InLocalStorageOptions, settings: ISettings, keys: KeyBuilderCS, splits: SplitsCacheInLocal, rbSegments: RBSegmentsCacheInLocal, segments: MySegmentsCacheInLocal, largeSegments: MySegmentsCacheInLocal): boolean { +export function validateCache(options: SplitIO.InLocalStorageOptions, storage: StorageAdapter, settings: ISettings, keys: KeyBuilderCS, splits: SplitsCacheInLocal, rbSegments: RBSegmentsCacheInLocal, segments: MySegmentsCacheInLocal, largeSegments: MySegmentsCacheInLocal): Promise { - const currentTimestamp = Date.now(); - const isThereCache = splits.getChangeNumber() > -1; + return Promise.resolve(storage.load && storage.load()).then(() => { + const currentTimestamp = Date.now(); + const isThereCache = splits.getChangeNumber() > -1; - if (validateExpiration(options, settings, keys, currentTimestamp, isThereCache)) { - splits.clear(); - rbSegments.clear(); - segments.clear(); - largeSegments.clear(); + if (validateExpiration(options, storage, settings, keys, currentTimestamp, isThereCache)) { + splits.clear(); + rbSegments.clear(); + segments.clear(); + largeSegments.clear(); - // Update last clear timestamp - try { - localStorage.setItem(keys.buildLastClear(), currentTimestamp + ''); - } catch (e) { - settings.log.error(LOG_PREFIX + e); - } + // Update last clear timestamp + try { + storage.setItem(keys.buildLastClear(), currentTimestamp + ''); + } catch (e) { + settings.log.error(LOG_PREFIX + e); + } - return false; - } + // Persist clear + if (storage.save) storage.save(); + + return false; + } - // Check if ready from cache - return isThereCache; + // Check if ready from cache + return isThereCache; + }); } diff --git a/src/storages/types.ts b/src/storages/types.ts index 2737da40..0fbbc4d7 100644 --- a/src/storages/types.ts +++ b/src/storages/types.ts @@ -4,6 +4,23 @@ import { MySegmentsData } from '../sync/polling/types'; import { EventDataType, HttpErrors, HttpLatencies, ImpressionDataType, LastSync, Method, MethodExceptions, MethodLatencies, MultiMethodExceptions, MultiMethodLatencies, MultiConfigs, OperationType, StoredEventWithMetadata, StoredImpressionWithMetadata, StreamingEvent, UniqueKeysPayloadCs, UniqueKeysPayloadSs, TelemetryUsageStatsPayload, UpdatesFromSSEEnum } from '../sync/submitters/types'; import { ISettings } from '../types'; +/** + * Internal interface based on a subset of the Web Storage API interface + * (https://developer.mozilla.org/en-US/docs/Web/API/Storage) used by the SDK + */ +export interface StorageAdapter { + // Methods to support async storages + load?: () => Promise; + save?: () => Promise; + whenSaved?: () => Promise; + // Methods based on https://developer.mozilla.org/en-US/docs/Web/API/Storage + readonly length: number; + key(index: number): string | null; + getItem(key: string): string | null; + removeItem(key: string): void; + setItem(key: string, value: string): void; +} + /** * Interface of a pluggable storage wrapper. */ @@ -467,6 +484,7 @@ export interface IStorageBase< uniqueKeys: TUniqueKeysCache, destroy(): void | Promise, shared?: (matchingKey: string, onReadyCb?: (error?: any) => void) => this + save?: () => void | Promise, } export interface IStorageSync extends IStorageBase< @@ -480,7 +498,7 @@ export interface IStorageSync extends IStorageBase< IUniqueKeysCacheSync > { // Defined in client-side - validateCache?: () => boolean, // @TODO support async + validateCache?: () => Promise, largeSegments?: ISegmentsCacheSync, } diff --git a/src/sync/__tests__/syncManagerOnline.spec.ts b/src/sync/__tests__/syncManagerOnline.spec.ts index c7dba96e..fc6cefb6 100644 --- a/src/sync/__tests__/syncManagerOnline.spec.ts +++ b/src/sync/__tests__/syncManagerOnline.spec.ts @@ -43,7 +43,7 @@ const pushManagerMock = { // Mocked pushManager const pushManagerFactoryMock = jest.fn(() => pushManagerMock); -test('syncManagerOnline should start or not the submitter depending on user consent status', () => { +test('syncManagerOnline should start or not the submitter depending on user consent status', async () => { const settings = { ...fullSettings }; const syncManager = syncManagerOnlineFactory()({ @@ -52,14 +52,14 @@ test('syncManagerOnline should start or not the submitter depending on user cons }); const submitterManager = syncManager.submitterManager!; - syncManager.start(); + await syncManager.start(); expect(submitterManager.start).toBeCalledTimes(1); expect(submitterManager.start).lastCalledWith(false); // SubmitterManager should start all submitters, if userConsent is undefined syncManager.stop(); expect(submitterManager.stop).toBeCalledTimes(1); settings.userConsent = 'UNKNOWN'; - syncManager.start(); + await syncManager.start(); expect(submitterManager.start).toBeCalledTimes(2); expect(submitterManager.start).lastCalledWith(true); // SubmitterManager should start only telemetry submitter, if userConsent is unknown syncManager.stop(); @@ -69,7 +69,7 @@ test('syncManagerOnline should start or not the submitter depending on user cons expect(submitterManager.execute).lastCalledWith(true); // SubmitterManager should flush only telemetry, if userConsent is unknown settings.userConsent = 'GRANTED'; - syncManager.start(); + await syncManager.start(); expect(submitterManager.start).toBeCalledTimes(3); expect(submitterManager.start).lastCalledWith(false); // SubmitterManager should start all submitters, if userConsent is granted syncManager.stop(); @@ -79,7 +79,7 @@ test('syncManagerOnline should start or not the submitter depending on user cons expect(submitterManager.execute).lastCalledWith(false); // SubmitterManager should flush all submitters, if userConsent is granted settings.userConsent = 'DECLINED'; - syncManager.start(); + await syncManager.start(); expect(submitterManager.start).toBeCalledTimes(4); expect(submitterManager.start).lastCalledWith(true); // SubmitterManager should start only telemetry submitter, if userConsent is declined syncManager.stop(); @@ -90,7 +90,7 @@ test('syncManagerOnline should start or not the submitter depending on user cons }); -test('syncManagerOnline should syncAll a single time when sync is disabled', () => { +test('syncManagerOnline should syncAll a single time when sync is disabled', async () => { const settings = { ...fullSettings }; // disable sync @@ -106,19 +106,19 @@ test('syncManagerOnline should syncAll a single time when sync is disabled', () expect(pushManagerFactoryMock).not.toBeCalled(); // Test pollingManager for Main client - syncManager.start(); + await syncManager.start(); expect(pollingManagerMock.start).not.toBeCalled(); expect(pollingManagerMock.syncAll).toBeCalledTimes(1); syncManager.stop(); - syncManager.start(); + await syncManager.start(); expect(pollingManagerMock.start).not.toBeCalled(); expect(pollingManagerMock.syncAll).toBeCalledTimes(1); syncManager.stop(); - syncManager.start(); + await syncManager.start(); expect(pollingManagerMock.start).not.toBeCalled(); expect(pollingManagerMock.syncAll).toBeCalledTimes(1); @@ -139,12 +139,12 @@ test('syncManagerOnline should syncAll a single time when sync is disabled', () pollingSyncManagerShared.stop(); - syncManager.start(); + await syncManager.start(); expect(pollingManagerMock.start).not.toBeCalled(); syncManager.stop(); - syncManager.start(); + await syncManager.start(); expect(pollingManagerMock.start).not.toBeCalled(); @@ -175,7 +175,7 @@ test('syncManagerOnline should syncAll a single time when sync is disabled', () expect(pushManagerFactoryMock).toBeCalled(); // Test pollingManager for Main client - testSyncManager.start(); + await testSyncManager.start(); expect(pushManagerMock.start).toBeCalled(); diff --git a/src/sync/offline/syncTasks/fromObjectSyncTask.ts b/src/sync/offline/syncTasks/fromObjectSyncTask.ts index acbb5f52..96bc8384 100644 --- a/src/sync/offline/syncTasks/fromObjectSyncTask.ts +++ b/src/sync/offline/syncTasks/fromObjectSyncTask.ts @@ -59,8 +59,7 @@ export function fromObjectUpdaterFactory( if (startingUp) { startingUp = false; - const isCacheLoaded = storage.validateCache ? storage.validateCache() : false; - Promise.resolve().then(() => { + Promise.resolve(storage.validateCache ? storage.validateCache() : false).then((isCacheLoaded) => { // Emits SDK_READY_FROM_CACHE if (isCacheLoaded) readiness.splits.emit(SDK_SPLITS_CACHE_LOADED); // Emits SDK_READY diff --git a/src/sync/polling/updaters/mySegmentsUpdater.ts b/src/sync/polling/updaters/mySegmentsUpdater.ts index 5de512fa..4b6038c5 100644 --- a/src/sync/polling/updaters/mySegmentsUpdater.ts +++ b/src/sync/polling/updaters/mySegmentsUpdater.ts @@ -51,6 +51,8 @@ export function mySegmentsUpdaterFactory( shouldNotifyUpdate = largeSegments!.resetSegments((segmentsData as IMembershipsResponse).ls || {}) || shouldNotifyUpdate; } + if (storage.save) storage.save(); + // Notify update if required if (usesSegmentsSync(storage) && (shouldNotifyUpdate || readyOnAlreadyExistentState)) { readyOnAlreadyExistentState = false; diff --git a/src/sync/polling/updaters/splitChangesUpdater.ts b/src/sync/polling/updaters/splitChangesUpdater.ts index 0331bc43..6c6371e3 100644 --- a/src/sync/polling/updaters/splitChangesUpdater.ts +++ b/src/sync/polling/updaters/splitChangesUpdater.ts @@ -117,7 +117,7 @@ export function computeMutation(rules: Array, export function splitChangesUpdaterFactory( log: ILogger, splitChangesFetcher: ISplitChangesFetcher, - storage: Pick, + storage: Pick, splitFiltersValidation: ISplitFiltersValidation, splitsEventEmitter?: ISplitsEventEmitter, requestTimeoutBeforeReady: number = 0, @@ -185,6 +185,8 @@ export function splitChangesUpdaterFactory( // @TODO if at least 1 segment fetch fails due to 404 and other segments are updated in the storage, SDK_UPDATE is not emitted segments.registerSegments(setToArray(usedSegments)) ]).then(([ffChanged, rbsChanged]) => { + if (storage.save) storage.save(); + if (splitsEventEmitter) { // To emit SDK_SPLITS_ARRIVED for server-side SDK, we must check that all registered segments have been fetched return Promise.resolve(!splitsEventEmitter.splitsArrived || ((ffChanged || rbsChanged) && (isClientSide || checkAllSegmentsExist(segments)))) diff --git a/src/sync/syncManagerOnline.ts b/src/sync/syncManagerOnline.ts index aac6f7e4..92dbc28f 100644 --- a/src/sync/syncManagerOnline.ts +++ b/src/sync/syncManagerOnline.ts @@ -89,36 +89,41 @@ export function syncManagerOnlineFactory( start() { running = true; - if (startFirstTime) { - const isCacheLoaded = storage.validateCache ? storage.validateCache() : false; - if (isCacheLoaded) Promise.resolve().then(() => { readiness.splits.emit(SDK_SPLITS_CACHE_LOADED); }); - } + // @TODO once event, impression and telemetry storages support persistence, call when `validateCache` promise is resolved + submitterManager.start(!isConsentGranted(settings)); - // start syncing splits and segments - if (pollingManager) { + return Promise.resolve(storage.validateCache ? storage.validateCache() : false).then((isCacheLoaded) => { + if (!running) return; - // If synchronization is disabled pushManager and pollingManager should not start - if (syncEnabled) { - if (pushManager) { - // Doesn't call `syncAll` when the syncManager is resuming + if (startFirstTime) { + // Emits SDK_READY_FROM_CACHE + if (isCacheLoaded) readiness.splits.emit(SDK_SPLITS_CACHE_LOADED); + + } + + // start syncing splits and segments + if (pollingManager) { + + // If synchronization is disabled pushManager and pollingManager should not start + if (syncEnabled) { + if (pushManager) { + // Doesn't call `syncAll` when the syncManager is resuming + if (startFirstTime) { + pollingManager.syncAll(); + } + pushManager.start(); + } else { + pollingManager.start(); + } + } else { if (startFirstTime) { pollingManager.syncAll(); } - pushManager.start(); - } else { - pollingManager.start(); - } - } else { - if (startFirstTime) { - pollingManager.syncAll(); } } - } - - // start periodic data recording (events, impressions, telemetry). - submitterManager.start(!isConsentGranted(settings)); - startFirstTime = false; + startFirstTime = false; + }); }, /** diff --git a/src/utils/env/isLocalStorageAvailable.ts b/src/utils/env/isLocalStorageAvailable.ts index e062b57d..beb55c24 100644 --- a/src/utils/env/isLocalStorageAvailable.ts +++ b/src/utils/env/isLocalStorageAvailable.ts @@ -9,3 +9,23 @@ export function isLocalStorageAvailable(): boolean { return false; } } + +export function isValidStorageWrapper(wrapper: any): boolean { + return wrapper !== null && + typeof wrapper === 'object' && + typeof wrapper.setItem === 'function' && + typeof wrapper.getItem === 'function' && + typeof wrapper.removeItem === 'function'; +} + +export function isWebStorage(wrapper: any): boolean { + if (typeof wrapper.length === 'number') { + try { + wrapper.key(0); + return true; + } catch (e) { + return false; + } + } + return false; +} diff --git a/src/utils/settingsValidation/storage/storageCS.ts b/src/utils/settingsValidation/storage/storageCS.ts index 7d58af3d..ef78ad84 100644 --- a/src/utils/settingsValidation/storage/storageCS.ts +++ b/src/utils/settingsValidation/storage/storageCS.ts @@ -8,7 +8,7 @@ import { IStorageFactoryParams, IStorageSync } from '../../../storages/types'; export function __InLocalStorageMockFactory(params: IStorageFactoryParams): IStorageSync { const result = InMemoryStorageCSFactory(params); - result.validateCache = () => true; // to emit SDK_READY_FROM_CACHE + result.validateCache = () => Promise.resolve(true); // to emit SDK_READY_FROM_CACHE return result; } __InLocalStorageMockFactory.type = STORAGE_MEMORY; diff --git a/types/splitio.d.ts b/types/splitio.d.ts index 2680f8ef..eaa490f3 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -132,7 +132,7 @@ interface IPluggableSharedSettings { /** * Boolean value to indicate whether the logger should be enabled or disabled by default, or a log level string or a Logger object. * Passing a logger object is required to get descriptive log messages. Otherwise most logs will print with message codes. - * @see {@link https://help.split.io/hc/en-us/articles/360058730852-Browser-SDK#logging}. + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/browser-sdk/#logging}. * * Examples: * ``` @@ -179,7 +179,7 @@ interface IServerSideSharedSettings { /** * Your SDK key. * - * @see {@link https://help.split.io/hc/en-us/articles/360019916211-API-keys} + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/management-and-administration/account-settings/api-keys/} */ authorizationKey: string; /** @@ -282,7 +282,7 @@ interface IServerSideSharedSettings { eventsQueueSize?: number; /** * For mocking/testing only. The SDK will refresh the features mocked data when mode is set to "localhost" by defining the key. - * For more information see {@link https://help.split.io/hc/en-us/articles/360020564931-Node-js-SDK#localhost-mode} + * For more information see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/nodejs-sdk/#localhost-mode} * * @defaultValue `15` */ @@ -297,7 +297,7 @@ interface IServerSideSharedSettings { }; /** * Mocked features file path. For testing purposes only. For using this you should specify "localhost" as authorizationKey on core settings. - * @see {@link https://help.split.io/hc/en-us/articles/360020564931-Node-js-SDK#localhost-mode} + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/nodejs-sdk/#localhost-mode} * * @defaultValue `'$HOME/.split'` */ @@ -314,13 +314,13 @@ interface IClientSideSharedSettings { /** * Your SDK key. * - * @see {@link https://help.split.io/hc/en-us/articles/360019916211-API-keys} + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/management-and-administration/account-settings/api-keys/} */ authorizationKey: string; /** * Customer identifier. Whatever this means to you. * - * @see {@link https://help.split.io/hc/en-us/articles/360019916311-Traffic-type} + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/management-and-administration/fme-settings/traffic-types/} */ key: SplitIO.SplitKey; /** @@ -347,7 +347,7 @@ interface IClientSideSharedSettings { interface IClientSideSyncSharedSettings extends IClientSideSharedSettings, ISyncSharedSettings { /** * Mocked features map. For testing purposes only. For using this you should specify "localhost" as authorizationKey on core settings. - * @see {@link https://help.split.io/hc/en-us/articles/360020448791-JavaScript-SDK#localhost-mode} + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/javascript-sdk/#localhost-mode} */ features?: SplitIO.MockedFeaturesMap; /** @@ -442,7 +442,7 @@ interface IClientSideSyncSharedSettings extends IClientSideSharedSettings, ISync eventsQueueSize?: number; /** * For mocking/testing only. The SDK will refresh the features mocked data when mode is set to "localhost" by defining the key. - * For more information see {@link https://help.split.io/hc/en-us/articles/360020448791-JavaScript-SDK#localhost-mode} + * For more information see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/javascript-sdk/#localhost-mode} * * @defaultValue `15` */ @@ -463,6 +463,24 @@ interface IClientSideSyncSharedSettings extends IClientSideSharedSettings, ISync */ declare namespace SplitIO { + interface StorageWrapper { + /** + * Returns the value associated with the given key, or null if the key does not exist. + * If the operation is asynchronous, returns a Promise. + */ + getItem(key: string): string | null | Promise; + /** + * Sets the value for the given key, creating a new key/value pair if key does not exist. + * If the operation is asynchronous, returns a Promise. + */ + setItem(key: string, value: string): void | Promise; + /** + * Removes the key/value pair for the given key, if the key exists. + * If the operation is asynchronous, returns a Promise. + */ + removeItem(key: string): void | Promise; + } + /** * EventEmitter interface based on a subset of the Node.js EventEmitter methods. */ @@ -776,7 +794,7 @@ declare namespace SplitIO { /** * Attributes should be on object with values of type string, boolean, number (dates should be sent as millis since epoch) or array of strings or numbers. * - * @see {@link https://help.split.io/hc/en-us/articles/360020448791-JavaScript-SDK#attribute-syntax} + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/javascript-sdk/#attribute-syntax} */ type Attributes = { [attributeName: string]: AttributeType; @@ -788,7 +806,7 @@ declare namespace SplitIO { /** * Properties should be an object with values of type string, number, boolean or null. Size limit of ~31kb. * - * @see {@link https://help.split.io/hc/en-us/articles/360020448791-JavaScript-SDK#track} + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/javascript-sdk/#track} */ type Properties = { [propertyName: string]: string | number | boolean | null; @@ -978,6 +996,12 @@ declare namespace SplitIO { * @defaultValue `false` */ clearOnInit?: boolean; + /** + * Optional storage wrapper to persist rollout plan related data. If not provided, the SDK will use the default localStorage Web API. + * + * @defaultValue `window.localStorage` + */ + wrapper?: StorageWrapper; } /** * Storage for asynchronous (consumer) SDK. @@ -1052,7 +1076,7 @@ declare namespace SplitIO { * Impression listener interface. This is the interface that needs to be implemented * by the element you provide to the SDK as impression listener. * - * @see {@link https://help.split.io/hc/en-us/articles/360020564931-Node-js-SDK#listener} + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/nodejs-sdk/#listener} */ interface IImpressionListener { logImpression(data: ImpressionData): void; @@ -1187,7 +1211,7 @@ declare namespace SplitIO { /** * Settings interface for Browser SDK instances created with client-side API and synchronous storage (e.g., in-memory or local storage). * - * @see {@link https://help.split.io/hc/en-us/articles/360058730852-Browser-SDK#configuration} + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/browser-sdk/#configuration} */ interface IClientSideSettings extends IClientSideSyncSharedSettings, IPluggableSharedSettings { /** @@ -1208,20 +1232,20 @@ declare namespace SplitIO { /** * Settings interface for React Native SDK instances, with client-side API and synchronous storage. * - * @see {@link https://help.split.io/hc/en-us/articles/4406066357901-React-Native-SDK#configuration} + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/react-native-sdk/#configuration} */ interface IReactNativeSettings extends IClientSideSettings { } /** * Settings interface for Browser SDK instances created with client-side API and asynchronous storage (e.g., serverless environments with a persistent storage). * If your storage is synchronous (by default we use memory, which is sync) use SplitIO.IClientSideSettings instead. * - * @see {@link https://help.split.io/hc/en-us/articles/360058730852-Browser-SDK#sharing-state-with-a-pluggable-storage} + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/browser-sdk/#sharing-state-with-a-pluggable-storage} */ interface IClientSideAsyncSettings extends IClientSideSharedSettings, ISharedSettings, IPluggableSharedSettings { /** * The SDK mode. When using `PluggableStorage` as storage, the possible values are "consumer" and "consumer_partial". * - * @see {@link https://help.split.io/hc/en-us/articles/360058730852-Browser-SDK#sharing-state-with-a-pluggable-storage} + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/browser-sdk/#sharing-state-with-a-pluggable-storage} */ mode: 'consumer' | 'consumer_partial'; /** @@ -1307,7 +1331,7 @@ declare namespace SplitIO { /** * Settings interface for JavaScript SDK instances created on the browser, with client-side API and synchronous storage (e.g., in-memory or local storage). * - * @see {@link https://help.split.io/hc/en-us/articles/360020448791-JavaScript-SDK#configuration} + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/javascript-sdk/#configuration} */ interface IBrowserSettings extends IClientSideSyncSharedSettings, INonPluggableSharedSettings { /** @@ -1339,13 +1363,19 @@ declare namespace SplitIO { * @defaultValue `false` */ clearOnInit?: boolean; + /** + * Optional storage wrapper to persist rollout plan related data. If not provided, the SDK will use the default localStorage Web API. + * + * @defaultValue `window.localStorage` + */ + wrapper?: StorageWrapper; }; } /** * Settings interface for JavaScript SDK instances created on Node.js, with server-side API and synchronous in-memory storage. * If your storage is asynchronous (Redis for example) use SplitIO.INodeAsyncSettings instead. * - * @see {@link https://help.split.io/hc/en-us/articles/360020564931-Node-js-SDK#configuration} + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/nodejs-sdk/#configuration} */ interface INodeSettings extends IServerSideSharedSettings, ISyncSharedSettings, INonPluggableSharedSettings { /** @@ -1432,13 +1462,13 @@ declare namespace SplitIO { * Settings interface for JavaScript SDK instances created on Node.js, with asynchronous storage like Redis. * If your storage is synchronous (by default we use memory, which is sync) use SplitIO.INodeSettings instead. * - * @see {@link https://help.split.io/hc/en-us/articles/360020564931-Node-js-SDK#configuration} + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/nodejs-sdk/#configuration} */ interface INodeAsyncSettings extends IServerSideSharedSettings, ISharedSettings, INonPluggableSharedSettings { /** * The SDK mode. When using 'REDIS' storage type, the only possible value is "consumer", which is required. * - * @see {@link https://help.split.io/hc/en-us/articles/360020564931-Node-js-SDK#state-sharing-redis-integration} + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/nodejs-sdk/#state-sharing-redis-integration} */ mode: 'consumer'; /** @@ -1712,7 +1742,7 @@ declare namespace SplitIO { * Tracks an event to be fed to the results product on Split user interface. * * @param key - The key that identifies the entity related to this event. - * @param trafficType - The traffic type of the entity related to this event. See {@link https://help.split.io/hc/en-us/articles/360019916311-Traffic-type} + * @param trafficType - The traffic type of the entity related to this event. See {@link https://developer.harness.io/docs/feature-management-experimentation/management-and-administration/fme-settings/traffic-types/} * @param eventType - The event type corresponding to this event. * @param value - The value of this event. * @param properties - The properties of this event. Values can be string, number, boolean or null. @@ -1810,7 +1840,7 @@ declare namespace SplitIO { * Tracks an event to be fed to the results product on Split user interface, and returns a promise to signal when the event was successfully queued (or not). * * @param key - The key that identifies the entity related to this event. - * @param trafficType - The traffic type of the entity related to this event. See {@link https://help.split.io/hc/en-us/articles/360019916311-Traffic-type} + * @param trafficType - The traffic type of the entity related to this event. See {@link https://developer.harness.io/docs/feature-management-experimentation/management-and-administration/fme-settings/traffic-types/} * @param eventType - The event type corresponding to this event. * @param value - The value of this event. * @param properties - The properties of this event. Values can be string, number, boolean or null. @@ -1940,7 +1970,7 @@ declare namespace SplitIO { /** * Tracks an event to be fed to the results product on Split user interface. * - * @param trafficType - The traffic type of the entity related to this event. See {@link https://help.split.io/hc/en-us/articles/360019916311-Traffic-type} + * @param trafficType - The traffic type of the entity related to this event. See {@link https://developer.harness.io/docs/feature-management-experimentation/management-and-administration/fme-settings/traffic-types/} * @param eventType - The event type corresponding to this event. * @param value - The value of this event. * @param properties - The properties of this event. Values can be string, number, boolean or null.