UNPKG

vwo-fme-node-sdk

Version:

VWO Node/JavaScript SDK for Feature Management and Experimentation

306 lines (283 loc) 11.3 kB
/** * Copyright 2024-2025 Wingify Software Pvt. Ltd. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { Constants } from '../../../constants'; import { Deferred } from '../../../utils/PromiseUtil'; import { LogManager } from '../../logger'; import { SettingsService } from '../../../services/SettingsService'; import { SettingsSchema } from '../../../models/schemas/SettingsSchemaValidation'; import { isNumber, isBoolean } from '../../../utils/DataTypeUtil'; /** * Interface representing the structure of data to be stored * @interface StorageData */ export interface StorageData { rolloutId?: string; rolloutKey?: string; rolloutVariationId?: string; experimentKey?: string; experimentId?: string; experimentVariationId?: string; [key: string]: any; } /** * Interface for configuring the storage connector * @interface ClientStorageOptions */ export interface ClientStorageOptions { key?: string; provider?: Storage; isDisabled?: boolean; alwaysUseCachedSettings?: boolean; ttl?: number; // Custom TTL in milliseconds } /** * A class that provides browser storage functionality for managing feature flags and experiments data * @class BrowserStorageConnector */ export class BrowserStorageConnector { private storage: Storage; private readonly storageKey: string; private readonly isDisabled: boolean; private readonly alwaysUseCachedSettings: boolean; private readonly ttl: number; private readonly SETTINGS_KEY: string = Constants.DEFAULT_SETTINGS_STORAGE_KEY; /** * Creates an instance of BrowserStorageConnector * @param {ClientStorageOptions} [options] - Configuration options for the storage connector * @param {string} [options.key] - Custom key for storage (defaults to Constants.DEFAULT_LOCAL_STORAGE_KEY) * @param {Storage} [options.provider] - Storage provider (defaults to window.localStorage) * @param {boolean} [options.isDisabled] - Whether storage operations should be disabled * @param {boolean} [options.alwaysUseCachedSettings] - Whether to always use cached settings * @param {number} [options.ttl] - Custom TTL in milliseconds (defaults to Constants.SETTINGS_TTL) */ constructor(options?: ClientStorageOptions) { this.storageKey = options?.key || Constants.DEFAULT_LOCAL_STORAGE_KEY; this.storage = options?.provider || window.localStorage; this.isDisabled = options?.isDisabled || false; this.alwaysUseCachedSettings = options?.alwaysUseCachedSettings || false; //options.ttl should be greater than 1 minute if (!isNumber(options?.ttl) || options.ttl < Constants.MIN_TTL_MS) { LogManager.Instance.debug('TTL is not passed or invalid (less than 1 minute), using default value of 2 hours'); this.ttl = Constants.SETTINGS_TTL; } else { this.ttl = options?.ttl || Constants.SETTINGS_TTL; } if (!isBoolean(options?.alwaysUseCachedSettings)) { LogManager.Instance.debug('AlwaysUseCachedSettings is not passed or invalid, using default value of false'); this.alwaysUseCachedSettings = false; } else { this.alwaysUseCachedSettings = options?.alwaysUseCachedSettings || false; } } /** * Retrieves all stored data from the storage * @private * @returns {Record<string, StorageData>} Object containing all stored data */ private getStoredData(): Record<string, StorageData> { if (this.isDisabled) return {}; try { const data = this.storage.getItem(this.storageKey); return data ? JSON.parse(data) : {}; } catch (error) { LogManager.Instance.error(`Error reading from storage: ${error}`); return {}; } } /** * Saves data to the storage * @private * @param {Record<string, StorageData>} data - The data object to be stored */ private storeData(data: Record<string, StorageData>): void { if (this.isDisabled) return; try { const serializedData = JSON.stringify(data); this.storage.setItem(this.storageKey, serializedData); } catch (error) { LogManager.Instance.error(`Error writing to storage: ${error}`); } } /** * Stores feature flag or experiment data for a specific user * @public * @param {StorageData} data - The data to be stored, containing feature flag or experiment information * @returns {Promise<void>} A promise that resolves when the data is successfully stored */ public set(data: StorageData): Promise<void> { const deferredObject = new Deferred(); if (this.isDisabled) { deferredObject.resolve(); } else { try { const storedData = this.getStoredData(); const key = `${data.featureKey}_${data.userId}`; storedData[key] = data; this.storeData(storedData); LogManager.Instance.info(`Stored data in storage for key: ${key}`); deferredObject.resolve(); } catch (error) { LogManager.Instance.error(`Error storing data: ${error}`); deferredObject.reject(error); } } return deferredObject.promise; } /** * Retrieves stored feature flag or experiment data for a specific user * @public * @param {string} featureKey - The key of the feature flag or experiment * @param {string} userId - The ID of the user * @returns {Promise<StorageData | Record<string, any>>} A promise that resolves to the stored data or {} if not found */ public get(featureKey: string, userId: string): Promise<StorageData | Record<string, any>> { const deferredObject = new Deferred(); if (this.isDisabled) { deferredObject.resolve({}); } else { try { const storedData = this.getStoredData(); const key = `${featureKey}_${userId}`; const dataToReturn = storedData[key] ?? {}; LogManager.Instance.info(`Retrieved data from storage for key: ${key}`); deferredObject.resolve(dataToReturn); } catch (error) { LogManager.Instance.error(`Error retrieving data: ${error}`); deferredObject.resolve({}); } } return deferredObject.promise; } /** * Gets the settings from storage with TTL check and validates sdkKey and accountId * @public * @param {string} sdkKey - The sdkKey to match * @param {number|string} accountId - The accountId to match * @returns {Promise<Record<string, any> | null>} A promise that resolves to the settings or null if expired/not found/mismatch */ public getSettingsFromStorage(sdkKey: string, accountId: string | number): Promise<Record<string, any> | null> { const deferredObject = new Deferred(); if (this.isDisabled) { deferredObject.resolve(null); } else { try { const storedData = this.getStoredData(); const settingsData = storedData[this.SETTINGS_KEY]; if (!settingsData) { deferredObject.resolve(null); return deferredObject.promise; } const { data, timestamp } = settingsData; const currentTime = Date.now(); // Decode sdkKey if present if (data && data.sdkKey) { try { data.sdkKey = atob(data.sdkKey); } catch (e) { LogManager.Instance.error('Failed to decode sdkKey from storage'); } } // Check for sdkKey and accountId match if (!data || data.sdkKey !== sdkKey || String(data.accountId ?? data.a) !== String(accountId)) { LogManager.Instance.info('Cached settings do not match sdkKey/accountId, treating as cache miss'); deferredObject.resolve(null); return deferredObject.promise; } if (this.alwaysUseCachedSettings) { LogManager.Instance.info('Using cached settings as alwaysUseCachedSettings is enabled'); deferredObject.resolve(data); return deferredObject.promise; } if (currentTime - timestamp > this.ttl) { LogManager.Instance.info('Settings have expired, need to fetch new settings'); deferredObject.resolve(null); } else { // if settings are valid then return the existing settings and update the settings in storage with new timestamp LogManager.Instance.info('Retrieved valid settings from storage'); this.setFreshSettingsInStorage(); // Decode sdkKey if present if (data && data.sdkKey) { try { data.sdkKey = atob(data.sdkKey); } catch (e) { LogManager.Instance.error('Failed to decode sdkKey from storage'); } } deferredObject.resolve(data); } } catch (error) { LogManager.Instance.error(`Error retrieving settings: ${error}`); deferredObject.resolve(null); } } return deferredObject.promise; } /** * Fetches fresh settings and updates the storage with a new timestamp */ public setFreshSettingsInStorage(): void { // Fetch fresh settings asynchronously and update storage const settingsService = SettingsService.Instance; if (settingsService) { settingsService .fetchSettings() .then(async (freshSettings) => { if (freshSettings) { const isSettingsValid = new SettingsSchema().isSettingsValid(freshSettings); if (isSettingsValid) { await this.setSettingsInStorage(freshSettings); LogManager.Instance.info('Settings updated with fresh data from server'); } } }) .catch((error) => { LogManager.Instance.error(`Error fetching fresh settings: ${error}`); }); } } /** * Sets the settings in storage with current timestamp * @public * @param {Record<string, any>} settings - The settings data to be stored * @returns {Promise<void>} A promise that resolves when the settings are successfully stored */ public setSettingsInStorage(settings: Record<string, any>): Promise<void> { const deferredObject = new Deferred(); if (this.isDisabled) { deferredObject.resolve(); } else { try { const storedData = this.getStoredData(); // Clone settings to avoid mutating the original object const settingsToStore = { ...settings }; if (settingsToStore.sdkKey) { settingsToStore.sdkKey = btoa(settingsToStore.sdkKey); } storedData[this.SETTINGS_KEY] = { data: settingsToStore, timestamp: Date.now(), }; this.storeData(storedData); LogManager.Instance.info('Settings stored successfully in storage'); deferredObject.resolve(); } catch (error) { LogManager.Instance.error(`Error storing settings: ${error}`); deferredObject.reject(error); } } return deferredObject.promise; } }