UNPKG

@devcycle/js-client-sdk

Version:

The Javascript Client SDK for DevCycle

1,314 lines (1,292 loc) 77.7 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var types$1 = require('@devcycle/types'); var chunk = require('lodash/chunk'); var fetchWithRetry = require('fetch-retry'); var uuid = require('uuid'); var UAParser = require('ua-parser-js'); var isNumber = require('lodash/isNumber'); function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; } var chunk__default = /*#__PURE__*/_interopDefaultLegacy(chunk); var fetchWithRetry__default = /*#__PURE__*/_interopDefaultLegacy(fetchWithRetry); var UAParser__default = /*#__PURE__*/_interopDefaultLegacy(UAParser); var isNumber__default = /*#__PURE__*/_interopDefaultLegacy(isNumber); const StoreKey = { User: 'dvc:user', AnonUserId: 'dvc:anonymous_user_id', AnonymousConfig: 'dvc:anonymous_config', IdentifiedConfig: 'dvc:identified_config', }; const convertToQueryFriendlyFormat = (property) => { if (property instanceof Date) { return property.getTime(); } if (typeof property === 'object') { return JSON.stringify(property); } return property; }; const serializeUserSearchParams = (user, queryParams) => { for (const key in user) { const userProperty = convertToQueryFriendlyFormat(user[key]); if (userProperty !== null && userProperty !== undefined) { queryParams.append(key, userProperty); } } }; const checkParamDefined = (name, param) => { if (!checkIfDefined(param)) { throw new Error(`Missing parameter: ${name}`); } }; const checkIfDefined = (variable) => { if (variable === undefined || variable === null) { return false; } return true; }; const checkParamType = (name, param, type) => { if (!param) { throw new Error(`Missing parameter: ${name}`); } if (typeof param !== type) { throw new Error(`${name} is not of type ${type}`); } }; // The `self` property is available only in WorkerScope environments (which don't have access to window) // ServiceWorkerGlobalScope is the name of the class when in a service worker environment // https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope // https://developer.mozilla.org/en-US/docs/Web/API/WorkerGlobalScope/self // function checkIsServiceWorker() { return (typeof self !== 'undefined' && self.constructor && self.constructor.name === 'ServiceWorkerGlobalScope'); } class DVCVariable { constructor(variable) { const { key, defaultValue } = variable; checkParamType('key', key, 'string'); checkParamDefined('defaultValue', defaultValue); this.key = key.toLowerCase(); if (variable.value === undefined || variable.value === null) { this.isDefaulted = true; this.value = defaultValue; } else { this.value = variable.value; this.isDefaulted = false; } this.defaultValue = variable.defaultValue; this.eval = variable.eval; this._feature = variable._feature || null; } onUpdate(callback) { checkParamType('callback', callback, 'function'); this.callback = callback; return this; } } const EventTypes = { variableEvaluated: 'variableEvaluated', variableDefaulted: 'variableDefaulted', }; class EventQueue { constructor(sdkKey, dvcClient, options) { var _a, _b; this.eventQueueBatchSize = 100; this.sdkKey = sdkKey; this.client = dvcClient; this.eventQueue = []; this.aggregateEventMap = {}; this.options = options; const eventFlushIntervalMS = typeof options.eventFlushIntervalMS === 'number' ? options.eventFlushIntervalMS : 10 * 1000; if (eventFlushIntervalMS < 500) { throw new Error(`eventFlushIntervalMS: ${eventFlushIntervalMS} must be larger than 500ms`); } else if (eventFlushIntervalMS > 60 * 1000) { throw new Error(`eventFlushIntervalMS: ${eventFlushIntervalMS} must be smaller than 1 minute`); } this.flushInterval = setInterval(this.flushEvents.bind(this), eventFlushIntervalMS); this.flushEventQueueSize = (_a = options === null || options === void 0 ? void 0 : options.flushEventQueueSize) !== null && _a !== void 0 ? _a : 100; this.maxEventQueueSize = (_b = options === null || options === void 0 ? void 0 : options.maxEventQueueSize) !== null && _b !== void 0 ? _b : 1000; if (this.flushEventQueueSize >= this.maxEventQueueSize) { throw new Error(`flushEventQueueSize: ${this.flushEventQueueSize} must be smaller than ` + `maxEventQueueSize: ${this.maxEventQueueSize}`); } else if (this.flushEventQueueSize < 10 || this.flushEventQueueSize > 1000) { throw new Error(`flushEventQueueSize: ${this.flushEventQueueSize} must be between 10 and 1000`); } else if (this.maxEventQueueSize < 100 || this.maxEventQueueSize > 5000) { throw new Error(`maxEventQueueSize: ${this.maxEventQueueSize} must be between 100 and 5000`); } } async flushEvents() { const user = this.client.user; if (!user) { this.client.logger.warn('Skipping event flush, user has not been set yet.'); return; } const eventsToFlush = [...this.eventQueue]; const aggregateEventsToFlush = this.eventsFromAggregateEventMap(); eventsToFlush.push(...aggregateEventsToFlush); if (!eventsToFlush.length) { return; } this.client.logger.info(`Flush ${eventsToFlush.length} Events`); this.eventQueue = []; this.aggregateEventMap = {}; const eventRequests = chunk__default["default"](eventsToFlush, this.eventQueueBatchSize); for (const eventRequest of eventRequests) { try { const res = await publishEvents(this.sdkKey, this.client.config, user, eventRequest, this.client.logger, this.options); if (res.status === 201) { this.client.logger.info(`DevCycle Flushed ${eventRequest.length} Events.`); } else if (res.status >= 500 || res.status === 408) { this.client.logger.warn('failed to flush events, retrying events. ' + `Response status: ${res.status}, message: ${res.statusText}`); this.eventQueue.push(...eventRequest); } else { this.client.logger.error('failed to flush events, dropping events. ' + `Response status: ${res.status}, message: ${res.statusText}`); } } catch (ex) { this.client.eventEmitter.emitError(ex); this.client.logger.error('failed to flush events due to error, dropping events. ' + `Error message: ${ex === null || ex === void 0 ? void 0 : ex.message}`); } } } /** * Queue DVCAPIEvent for producing */ queueEvent(event) { if (this.checkEventQueueSize()) { this.client.logger.warn(`DevCycle: Max event queue size (${this.maxEventQueueSize}) reached, dropping event: ${event}`); return; } this.eventQueue.push(event); } /** * Queue DVCEvent that can be aggregated together, where multiple calls are aggregated * by incrementing the 'value' field. */ queueAggregateEvent(event) { if (this.checkEventQueueSize()) { this.client.logger.warn(`DevCycle: Max event queue size (${this.maxEventQueueSize}) reached, dropping event: ${event}`); return; } checkParamDefined('type', event.type); checkParamDefined('target', event.target); event.date = Date.now(); event.value = 1; const aggEventType = this.aggregateEventMap[event.type]; if (!aggEventType) { this.aggregateEventMap[event.type] = { [event.target]: event }; } else if (aggEventType[event.target]) { aggEventType[event.target].value++; } else { aggEventType[event.target] = event; } } checkEventQueueSize() { const aggCount = Object.values(this.aggregateEventMap).reduce((acc, v) => acc + Object.keys(v).length, 0); const queueSize = this.eventQueue.length + aggCount; if (queueSize >= this.flushEventQueueSize) { this.flushEvents(); } return queueSize >= this.maxEventQueueSize; } /** * Turn the Aggregate Event Map into an Array of DVCAPIEvent objects for publishing. */ eventsFromAggregateEventMap() { return Object.values(this.aggregateEventMap) .map((typeMap) => Object.values(typeMap)) .flat(); } async close() { clearInterval(this.flushInterval); await this.flushEvents(); } } function generateEventPayload(config, user, events) { return { events: events.map((event) => { return new DVCRequestEvent(event, user.user_id, config); }), user, }; } class DVCRequestEvent { constructor(event, user_id, config) { var _a; const { type, target, date, value, metaData } = event; checkParamDefined('type', type); const isCustomEvent = !(type in EventTypes); this.type = isCustomEvent ? 'customEvent' : type; this.customType = isCustomEvent ? type : undefined; this.target = target; this.user_id = user_id; this.clientDate = date || Date.now(); this.value = value; if (config && ((_a = config.settings) === null || _a === void 0 ? void 0 : _a.filterFeatureVars) && (type === 'variableEvaluated' || type === 'variableDefaulted') && target) { const variable = config === null || config === void 0 ? void 0 : config.variables[target]; const featureId = variable === null || variable === void 0 ? void 0 : variable._feature; this.featureVars = featureId && (config === null || config === void 0 ? void 0 : config.featureVariationMap[featureId]) ? { [featureId]: config === null || config === void 0 ? void 0 : config.featureVariationMap[featureId], } : {}; } else { this.featureVars = (config === null || config === void 0 ? void 0 : config.featureVariationMap) || {}; } this.metaData = metaData; } } // NOTE: This file is duplicated from "lib/shared/server-request" because nx:rollup cant build non-external dependencies class ResponseError extends Error { constructor(message) { super(message); this.name = 'ResponseError'; } } const exponentialBackoff = (attempt) => { const delay = Math.pow(2, attempt) * 100; const randomSum = delay * 0.2 * Math.random(); return delay + randomSum; }; const retryOnRequestError = (retries) => { return (attempt, error, response) => { if (attempt >= retries) { return false; } else if (response && (response === null || response === void 0 ? void 0 : response.status) < 500) { return false; } return true; }; }; async function handleResponse(res) { // res.ok only checks for 200-299 status codes if (!res.ok && res.status >= 400) { let error; try { const response = await res.clone().json(); error = new ResponseError(response.message || 'Something went wrong'); } catch (e) { error = new ResponseError('Something went wrong'); } error.status = res.status; throw error; } return res; } async function getWithTimeout(url, requestConfig, timeout) { var _a; const controller = new AbortController(); try { const id = setTimeout(() => { controller.abort(); }, timeout); const response = await get(url, { ...requestConfig, signal: controller.signal, }); clearTimeout(id); return response; } catch (e) { if ((_a = controller === null || controller === void 0 ? void 0 : controller.signal) === null || _a === void 0 ? void 0 : _a.aborted) { throw new Error('Network connection timed out.'); } else { throw e; } } } async function post(url, requestConfig, sdkKey) { const [_fetch, config] = await getFetchAndConfig(requestConfig); const postHeaders = { ...config.headers, Authorization: sdkKey, 'Content-Type': 'application/json', }; const res = await _fetch(url, { ...config, headers: postHeaders, method: 'POST', }); return handleResponse(res); } async function patch(url, requestConfig, sdkKey) { const [_fetch, config] = await getFetchAndConfig(requestConfig); const patchHeaders = { ...config.headers, Authorization: sdkKey, 'Content-Type': 'application/json', }; const res = await _fetch(url, { ...config, headers: patchHeaders, method: 'PATCH', }); return handleResponse(res); } async function get(url, requestConfig) { const [_fetch, config] = await getFetchAndConfig(requestConfig); const headers = { ...config.headers, 'Content-Type': 'application/json' }; const res = await _fetch(url, { ...config, headers, method: 'GET', }); return handleResponse(res); } function getFetchWithRetry() { return fetchWithRetry__default["default"](fetch); } async function getFetchAndConfig(requestConfig) { const useRetries = 'retries' in requestConfig; if (useRetries && requestConfig.retries) { const newConfig = { ...requestConfig }; newConfig.retryOn = retryOnRequestError(requestConfig.retries); newConfig.retryDelay = exponentialBackoff; return [getFetchWithRetry(), newConfig]; } return [fetch, requestConfig]; } const HOST = '.devcycle.com'; const CLIENT_SDK_URL = 'https://sdk-api' + HOST; const EVENT_URL = 'https://events' + HOST; const CONFIG_PATH = '/v1/sdkConfig'; const EVENTS_PATH = '/v1/events'; const SAVE_ENTITY_PATH = '/v1/edgedb'; const requestConfig = { retries: 5, retryDelay: exponentialBackoff, }; /** * Endpoints */ const getConfigJson = async (sdkKey, user, logger, options, extraParams) => { const queryParams = new URLSearchParams({ sdkKey }); serializeUserSearchParams(user, queryParams); if (options === null || options === void 0 ? void 0 : options.enableEdgeDB) { queryParams.append('enableEdgeDB', options.enableEdgeDB.toString()); } if (extraParams === null || extraParams === void 0 ? void 0 : extraParams.sse) { queryParams.append('sse', '1'); if (extraParams.lastModified) { queryParams.append('sseLastModified', extraParams.lastModified.toString()); } if (extraParams.etag) { queryParams.append('sseEtag', extraParams.etag); } } if (options === null || options === void 0 ? void 0 : options.enableObfuscation) { queryParams.append('obfuscated', '1'); } const url = `${(options === null || options === void 0 ? void 0 : options.apiProxyURL) || CLIENT_SDK_URL}${CONFIG_PATH}?` + queryParams.toString(); try { const res = await getWithTimeout(url, requestConfig, 5000); return await res.json(); } catch (e) { logger.error(`Request to get config failed for url: ${url}, ` + `response message: ${e}`); if (e instanceof ResponseError) { if (e.status === 401 || e.status === 403) { throw new types$1.UserError(`Invalid SDK Key. Error details: ${e.message}`); } throw new Error(`Failed to download DevCycle config. Error details: ${e.message}`); } throw new Error(`Failed to download DevCycle config. Error details: ${e}`); } }; const publishEvents = async (sdkKey, config, user, events, logger, options) => { if (!sdkKey) { throw new Error('Missing sdkKey to publish events to Events API'); } const payload = generateEventPayload(config, user, events); logger.info(`Submit Events Payload: ${JSON.stringify(payload)}`); let url = `${(options === null || options === void 0 ? void 0 : options.apiProxyURL) || EVENT_URL}${EVENTS_PATH}`; if (options === null || options === void 0 ? void 0 : options.enableObfuscation) { url += '?obfuscated=1'; } const res = await post(url, { ...requestConfig, body: JSON.stringify(payload), }, sdkKey); const data = await res.json(); if (res.status >= 400) { logger.error(`Error posting events, status: ${res.status}, body: ${data}`); } else { logger.info(`Posted Events, status: ${res.status}, body: ${data}`); } return res; }; const saveEntity = async (user, sdkKey, logger, options) => { if (!sdkKey) { throw new Error('Missing sdkKey to save to Edge DB!'); } if (!user || !user.user_id) { throw new Error('Missing user to save to Edge DB!'); } if (user.isAnonymous) { throw new Error('Cannot save user data for an anonymous user!'); } try { return await patch(`${(options === null || options === void 0 ? void 0 : options.apiProxyURL) || CLIENT_SDK_URL}${SAVE_ENTITY_PATH}/${encodeURIComponent(user.user_id)}`, { ...requestConfig, body: JSON.stringify(user), }, sdkKey); } catch (e) { const error = e; if (error.status === 403) { logger.warn('Warning: EdgeDB feature is not enabled for this project'); } else if (error.status >= 400) { logger.warn(`Error saving user entity, status: ${error.status}, body: ${error.message}`); } else { logger.info(`Saved user entity, status: ${error.status}, body: ${error.message}`); } return; } }; var name = "@devcycle/js-client-sdk"; var version = "1.47.1"; var description = "The Javascript Client SDK for DevCycle"; var author = "DevCycle <support@devcycle.com>"; var keywords = [ "devcycle", "feature flag", "javascript", "client", "sdk" ]; var license = "MIT"; var homepage = "https://devcycle.com"; var typesVersions = { "<4.0": { "*": [ "./ts3.5/*" ] } }; var types = "./index.cjs.d.ts"; var devDependencies = { "cross-fetch": "^4.1.0" }; var dependencies = { "@devcycle/types": "1.32.0", "fetch-retry": "^5.0.6", lodash: "^4.17.21", "ua-parser-js": "^1.0.40", uuid: "^8.3.2" }; var repository = { type: "git", url: "git://github.com/DevCycleHQ/js-sdks.git" }; var packageJson = { name: name, version: version, description: description, author: author, keywords: keywords, license: license, homepage: homepage, typesVersions: typesVersions, types: types, devDependencies: devDependencies, dependencies: dependencies, repository: repository }; class DVCPopulatedUser { generateAndSaveAnonUserId(anonymousUserId, store) { const userId = anonymousUserId || uuid.v4(); // Save newly generated anonymous user ID to storage if (!anonymousUserId && store) { void store.saveAnonUserId(userId); } return userId; } constructor(user, options, staticData, anonymousUserId, headerUserAgent, store) { // Treat empty string user_id as null const normalizedUserId = user.user_id === '' ? undefined : user.user_id; // Validate required fields if (user.isAnonymous === false && !normalizedUserId) { throw new Error('A User cannot be created with isAnonymous: false without a valid user_id'); } // Set user_id and isAnonymous based on the input if (normalizedUserId) { // Case: { user_id: 'abc' } or { user_id: 'abc', isAnonymous: false/true } this.user_id = normalizedUserId; this.isAnonymous = false; } else { // Case: {} (empty object) or { isAnonymous: true } - set as anonymous this.user_id = this.generateAndSaveAnonUserId(anonymousUserId, store); this.isAnonymous = true; } this.email = user.email; this.name = user.name; this.language = user.language; this.country = user.country; this.appVersion = user.appVersion; this.appBuild = user.appBuild; this.customData = user.customData; this.privateCustomData = user.privateCustomData; this.lastSeenDate = new Date(); const userAgentString = typeof window !== 'undefined' ? window.navigator.userAgent : headerUserAgent; /** * Read only properties initialized once */ if (staticData) { Object.assign(this, staticData); } else { const userAgent = new UAParser__default["default"](userAgentString); const platformVersion = userAgent.getBrowser().name && `${userAgent.getBrowser().name} ${userAgent.getBrowser().version}`; this.createdDate = new Date(); this.platform = (options === null || options === void 0 ? void 0 : options.reactNative) ? 'ReactNative' : 'web'; this.platformVersion = platformVersion !== null && platformVersion !== void 0 ? platformVersion : 'unknown'; this.deviceModel = (options === null || options === void 0 ? void 0 : options.reactNative) && globalThis.DeviceInfo ? globalThis.DeviceInfo.getModel() : userAgentString !== null && userAgentString !== void 0 ? userAgentString : 'SSR - unknown'; this.sdkType = 'client'; this.sdkVersion = packageJson.version; this.sdkPlatform = options === null || options === void 0 ? void 0 : options.sdkPlatform; } } getStaticData() { return { createdDate: this.createdDate, platform: this.platform, platformVersion: this.platformVersion, deviceModel: this.deviceModel, sdkType: this.sdkType, sdkVersion: this.sdkVersion, sdkPlatform: this.sdkPlatform, }; } updateUser(user, options) { if (this.user_id !== user.user_id) { throw new Error('Cannot update a user with a different user_id'); } return new DVCPopulatedUser(user, options, this.getStaticData()); } } const DEFAULT_CONFIG_CACHE_TTL = 30 * 24 * 60 * 60 * 1000; // 30 days in milliseconds class CacheStore { constructor(storage, logger, configCacheTTL = DEFAULT_CONFIG_CACHE_TTL) { this.store = storage; this.logger = logger; this.configCacheTTL = configCacheTTL; } getConfigKey(user) { return user.isAnonymous ? `${StoreKey.AnonymousConfig}.${user.user_id}` : `${StoreKey.IdentifiedConfig}.${user.user_id}`; } getConfigExpiryKey(user) { return `${this.getConfigKey(user)}.expiry_date`; } async loadConfigExpiryDate(user) { const expiryKey = this.getConfigExpiryKey(user); const expiryDate = (await this.store.load(expiryKey)) || '0'; return parseInt(expiryDate, 10); } async saveConfig(data, user) { var _a; const configKey = this.getConfigKey(user); const expiryKey = this.getConfigExpiryKey(user); const now = Date.now(); const expiryDate = now + this.configCacheTTL; await Promise.all([ this.store.save(configKey, data), this.store.save(expiryKey, expiryDate), ]); (_a = this.logger) === null || _a === void 0 ? void 0 : _a.info(`Successfully saved config for user ${user.user_id} to local storage`); } isBucketedUserConfig(object) { if (!object || typeof object !== 'object') return false; return ('features' in object && 'project' in object && 'environment' in object && 'featureVariationMap' in object && 'variableVariationMap' in object && 'variables' in object); } async loadConfig(user) { var _a, _b, _c; await this.migrateLegacyConfigs(); // Run cleanup periodically to remove old expired configs await this.cleanupExpiredConfigs(); // Load config using the new user-specific key format const configKey = this.getConfigKey(user); const config = await this.store.load(configKey); if (!config) { // No config found (_a = this.logger) === null || _a === void 0 ? void 0 : _a.debug('Skipping cached config: no config found'); return null; } // Check if config is valid if (!this.isBucketedUserConfig(config)) { (_b = this.logger) === null || _b === void 0 ? void 0 : _b.debug(`Skipping cached config: invalid config found: ${JSON.stringify(config)}`); return null; } // Check if the config is expired const expiryDate = await this.loadConfigExpiryDate(user); const isConfigExpired = Date.now() > expiryDate; if (isConfigExpired) { (_c = this.logger) === null || _c === void 0 ? void 0 : _c.debug('Skipping cached config: config has expired'); // Remove expired config and expiry date from storage const expiryKey = this.getConfigExpiryKey(user); await Promise.all([ this.store.remove(configKey), this.store.remove(expiryKey), ]); return null; } return config; } async saveAnonUserId(userId) { var _a; await this.store.save(StoreKey.AnonUserId, userId); (_a = this.logger) === null || _a === void 0 ? void 0 : _a.info('Successfully saved anonymous user id to local storage'); } async loadAnonUserId() { return await this.store.load(StoreKey.AnonUserId); } async removeAnonUserId() { const anonUserId = await this.loadAnonUserId(); if (anonUserId) { // Remove anonymous config data for this user const configKey = `${StoreKey.AnonymousConfig}.${anonUserId}`; const expiryKey = `${configKey}.expiry_date`; await Promise.all([ this.store.remove(configKey), this.store.remove(expiryKey), ]); } await this.store.remove(StoreKey.AnonUserId); } async migrateLegacyConfigs() { // Migrate both anonymous and identified legacy configs await Promise.all([ this.migrateLegacyConfigType(StoreKey.AnonymousConfig, true), this.migrateLegacyConfigType(StoreKey.IdentifiedConfig, false), ]); } async migrateLegacyConfigType(legacyKey, isAnonymous) { var _a, _b; try { const legacyConfig = await this.store.load(legacyKey); if (!legacyConfig || !this.isBucketedUserConfig(legacyConfig)) { return; } // Get the user ID from the legacy format const userIdKey = `${legacyKey}.user_id`; const userId = await this.store.load(userIdKey); if (!userId) { return; } // Create a minimal user object for migration const user = new DVCPopulatedUser({ user_id: userId, isAnonymous }, {}); // Check if new format already exists const fetchDateKey = `${legacyKey}.fetch_date`; const newConfigKey = this.getConfigKey(user); const newConfig = await this.store.load(newConfigKey); if (newConfig) { // New format already exists, clean up legacy await Promise.all([ this.store.remove(legacyKey), this.store.remove(userIdKey), this.store.remove(fetchDateKey), this.store.remove(StoreKey.User), ]); return; } // Migrate to new format with expiry date (_a = this.logger) === null || _a === void 0 ? void 0 : _a.debug(`Migrating legacy ${isAnonymous ? 'anonymous' : 'identified'} config for user ${userId} to new format`); await this.saveConfig(legacyConfig, user); // Clean up legacy storage including stored user await Promise.all([ this.store.remove(legacyKey), this.store.remove(userIdKey), this.store.remove(fetchDateKey), this.store.remove(StoreKey.User), ]); } catch (error) { (_b = this.logger) === null || _b === void 0 ? void 0 : _b.debug(`Failed to migrate legacy config from ${legacyKey}: ${error}`); } } /** * Clean up expired config entries from storage. * This removes configs for any user that have passed their expiry date. */ async cleanupExpiredConfigs() { var _a, _b, _c, _d; if (!this.store.listKeys) { (_a = this.logger) === null || _a === void 0 ? void 0 : _a.debug('Storage does not support key enumeration, skipping cleanup'); return; } try { const now = Date.now(); const prefixes = [ StoreKey.AnonymousConfig, StoreKey.IdentifiedConfig, ]; for (const prefix of prefixes) { const keys = await this.store.listKeys(prefix); for (const key of keys) { // Skip if this is an expiry key itself if (key.endsWith('.expiry_date')) continue; const expiryKey = `${key}.expiry_date`; const expiryDate = await this.store.load(expiryKey); if (expiryDate) { const expiry = parseInt(expiryDate, 10); if (now > expiry) { (_b = this.logger) === null || _b === void 0 ? void 0 : _b.debug(`Cleaning up expired config: ${key}`); await Promise.all([ this.store.remove(key), this.store.remove(expiryKey), ]); } } else { // Config without expiry date (legacy or orphaned), remove it (_c = this.logger) === null || _c === void 0 ? void 0 : _c.debug(`Cleaning up orphaned config: ${key}`); await this.store.remove(key); } } } } catch (error) { (_d = this.logger) === null || _d === void 0 ? void 0 : _d.debug(`Failed to cleanup expired configs: ${error}`); } } } class StorageStrategy { } // LocalStorage implementation class LocalStorageStrategy extends StorageStrategy { constructor(isTesting = false) { super(); this.isTesting = isTesting; this.init(); } async init() { this.store = this.isTesting ? stubbedLocalStorage : window.localStorage; } async save(storeKey, data) { this.store.setItem(storeKey, JSON.stringify(data)); } async load(storeKey) { const item = this.store.getItem(storeKey); return item ? JSON.parse(item) : undefined; } async remove(storeKey) { this.store.removeItem(storeKey); } async listKeys(prefix) { const keys = []; for (let i = 0; i < this.store.length; i++) { const key = this.store.key(i); if (key && key.startsWith(prefix)) { keys.push(key); } } return keys; } } const stubbedLocalStorage = { getItem: () => null, setItem: () => undefined, removeItem: () => undefined, clear: () => undefined, key: () => null, length: 0, }; // IndexedDB implementation class IndexedDBStrategy extends StorageStrategy { constructor() { super(); this.connectionPromise = new Promise((resolve, reject) => { this.init() .then((db) => { this.store = db; this.isReady = true; resolve(); }) .catch((err) => reject(err)); }); } async init() { return new Promise((resolve, reject) => { const request = indexedDB.open(IndexedDBStrategy.DBName, 1); request.onupgradeneeded = (event) => { const db = request.result; if (!db.objectStoreNames.contains(IndexedDBStrategy.storeName)) { db.createObjectStore(IndexedDBStrategy.storeName, { keyPath: 'id', }); } }; request.onsuccess = (event) => { resolve(request.result); }; request.onerror = (event) => { reject(request.error); }; }); } async save(storeKey, data) { await this.connectionPromise; const tx = this.store.transaction(IndexedDBStrategy.storeName, 'readwrite'); const store = tx.objectStore(IndexedDBStrategy.storeName); store.put({ id: storeKey, data: data }); } // IndexedDB load async load(storeKey) { await this.connectionPromise; const tx = this.store.transaction(IndexedDBStrategy.storeName, 'readonly'); const store = tx.objectStore(IndexedDBStrategy.storeName); const request = store.get(storeKey); return new Promise((resolve, reject) => { request.onsuccess = () => { resolve(request.result ? request.result.data : undefined); }; request.onerror = () => reject(request.error); }); } // IndexedDB remove async remove(storeKey) { await this.connectionPromise; const tx = this.store.transaction(IndexedDBStrategy.storeName, 'readwrite'); const store = tx.objectStore(IndexedDBStrategy.storeName); store.delete(storeKey); } async listKeys(prefix) { const keys = []; await this.connectionPromise; const tx = this.store.transaction(IndexedDBStrategy.storeName, 'readonly'); const store = tx.objectStore(IndexedDBStrategy.storeName); const request = store.openCursor(); return new Promise((resolve, reject) => { request.onsuccess = (event) => { const cursor = event.target.result; if (cursor) { const key = cursor.key; if (key.startsWith(prefix)) { keys.push(key); } cursor.continue(); } else { resolve(keys); } }; request.onerror = () => reject(request.error); }); } } IndexedDBStrategy.storeName = 'DevCycleStore'; IndexedDBStrategy.DBName = 'DevCycleDB'; function getStorageStrategy() { if (checkIsServiceWorker()) { return new IndexedDBStrategy(); } else { return new LocalStorageStrategy(typeof window === 'undefined'); } } const EventNames = { INITIALIZED: 'initialized', NEW_VARIABLES: 'newVariables', ERROR: 'error', VARIABLE_UPDATED: 'variableUpdated', FEATURE_UPDATED: 'featureUpdated', CONFIG_UPDATED: 'configUpdated', VARIABLE_EVALUATED: 'variableEvaluated', }; const isInvalidEventKey = (key) => { return (!Object.values(EventNames).includes(key) && !key.startsWith(EventNames.VARIABLE_UPDATED) && !key.startsWith(EventNames.FEATURE_UPDATED) && !key.startsWith(EventNames.NEW_VARIABLES) && !key.startsWith(EventNames.VARIABLE_EVALUATED)); }; class EventEmitter { constructor() { this.handlers = {}; } subscribe(key, handler) { checkParamType('key', key, 'string'); checkParamType('handler', handler, 'function'); if (isInvalidEventKey(key)) { throw new Error('Not a valid event to subscribe to'); } if (!this.handlers[key]) { this.handlers[key] = [handler]; } else { this.handlers[key].push(handler); } } unsubscribe(key, handler) { checkParamType('key', key, 'string'); if (isInvalidEventKey(key)) { return; } if (handler) { const handlerIndex = this.handlers[key].findIndex((h) => h === handler); this.handlers[key].splice(handlerIndex, 1); } else { this.handlers[key] = []; } } emit(key, ...args) { var _a; checkParamType('key', key, 'string'); (_a = this.handlers[key]) === null || _a === void 0 ? void 0 : _a.forEach((handler) => { new Promise((resolve) => { handler(...args); resolve(true); }); }); } emitInitialized(success) { this.emit(EventNames.INITIALIZED, success); } emitError(error) { this.emit(EventNames.ERROR, error); } emitConfigUpdate(newVariableSet) { this.emit(EventNames.CONFIG_UPDATED, newVariableSet); } emitVariableEvaluated(variable) { this.emit(`${EventNames.VARIABLE_EVALUATED}:*`, variable.key, variable); this.emit(`${EventNames.VARIABLE_EVALUATED}:${variable.key}`, variable.key, variable); } emitVariableUpdates(oldVariableSet, newVariableSet, variableDefaultMap) { const keys = new Set(Object.keys(oldVariableSet).concat(Object.keys(newVariableSet))); let newVariables = false; keys.forEach((key) => { const oldVariableValue = oldVariableSet[key] && oldVariableSet[key].value; const newVariable = newVariableSet[key]; const newVariableValue = newVariable && newVariableSet[key].value; if (JSON.stringify(oldVariableValue) !== JSON.stringify(newVariableValue)) { const variables = variableDefaultMap[key] && Object.values(variableDefaultMap[key]); if (variables) { newVariables = true; variables.forEach((variable) => { var _a; variable.value = newVariableValue !== null && newVariableValue !== void 0 ? newVariableValue : variable.defaultValue; variable.isDefaulted = newVariableValue === undefined || newVariableValue === null; (_a = variable.callback) === null || _a === void 0 ? void 0 : _a.call(variable, variable.value); }); } const finalVariable = newVariable || null; this.emit(`${EventNames.VARIABLE_UPDATED}:*`, key, finalVariable); this.emit(`${EventNames.VARIABLE_UPDATED}:${key}`, key, finalVariable); } }); if (newVariables) { this.emit(`${EventNames.NEW_VARIABLES}`); } } emitFeatureUpdates(oldFeatureSet, newFeatureSet) { const keys = Object.keys(oldFeatureSet).concat(Object.keys(newFeatureSet)); keys.forEach((key) => { const oldFeatureVariation = oldFeatureSet[key] && oldFeatureSet[key]._variation; const newFeature = newFeatureSet[key]; const newFeatureVariation = newFeature && newFeatureSet[key]._variation; const finalFeature = newFeature || null; if (oldFeatureVariation !== newFeatureVariation) { this.emit(`${EventNames.FEATURE_UPDATED}:*`, key, finalFeature); this.emit(`${EventNames.FEATURE_UPDATED}:${key}`, key, finalFeature); } }); } } /** * Ensures we only have one active config request at a time * any calls made while another is ongoing will be merged together by using the latest user object provided */ class ConfigRequestConsolidator { constructor(requestConfigFunction, handleConfigReceivedFunction, nextUser) { this.requestConfigFunction = requestConfigFunction; this.handleConfigReceivedFunction = handleConfigReceivedFunction; this.nextUser = nextUser; this.resolvers = []; this.requestParams = null; } async queue(user, requestParams) { if (user) { this.nextUser = user; } if (requestParams) { this.requestParams = requestParams; } const resolver = new Promise((resolve, reject) => { this.resolvers.push({ resolve, reject, }); }); if (!this.currentPromise) { this.processQueue(); } return resolver; } async processQueue() { if (!this.resolvers.length) { return; } const resolvers = this.resolvers.splice(0); await this.performRequest(this.nextUser) .then((result) => { if (this.resolvers.length) { // if more resolvers have been registered since this request was made, // don't resolve anything and just make another request while keeping all the previous resolvers this.resolvers.push(...resolvers); } else { this.handleConfigReceivedFunction(result, this.nextUser); resolvers.forEach(({ resolve }) => resolve(result)); } }) .catch((err) => { resolvers.forEach(({ reject }) => reject(err)); }); if (this.resolvers.length) { this.processQueue(); } } async performRequest(user) { this.currentPromise = this.requestConfigFunction(user, this.requestParams ? this.requestParams : undefined); this.requestParams = null; const bucketedConfig = await this.currentPromise.finally(() => { // clear the current promise so we can make another request // this should happen regardless of whether the request was successful or not this.currentPromise = null; }); return bucketedConfig; } } const prefix = '[DevCycle]: '; var DVCLogLevels; (function (DVCLogLevels) { DVCLogLevels[DVCLogLevels["debug"] = 0] = "debug"; DVCLogLevels[DVCLogLevels["info"] = 1] = "info"; DVCLogLevels[DVCLogLevels["warn"] = 2] = "warn"; DVCLogLevels[DVCLogLevels["error"] = 3] = "error"; })(DVCLogLevels || (DVCLogLevels = {})); function dvcDefaultLogger(options) { const minLevel = (options === null || options === void 0 ? void 0 : options.level) && isNumber__default["default"](DVCLogLevels[options === null || options === void 0 ? void 0 : options.level]) ? DVCLogLevels[options === null || options === void 0 ? void 0 : options.level] : DVCLogLevels.error; const logWriter = (options === null || options === void 0 ? void 0 : options.logWriter) || console.log; const errorWriter = (options === null || options === void 0 ? void 0 : options.logWriter) || console.error; const writeLog = (message) => logWriter(prefix + message); const writeError = (message, error) => errorWriter(prefix + message, error); // eslint-disable-next-line @typescript-eslint/no-empty-function const noOpLog = (message) => { }; return { error: DVCLogLevels.error >= minLevel ? writeError : noOpLog, warn: DVCLogLevels.warn >= minLevel ? writeLog : noOpLog, info: DVCLogLevels.info >= minLevel ? writeLog : noOpLog, debug: DVCLogLevels.debug >= minLevel ? writeLog : noOpLog, }; } class StreamingConnection { constructor(url, onMessage, logger) { this.url = url; this.onMessage = onMessage; this.logger = logger; this.openConnection(); } updateURL(url) { this.close(); this.url = url; this.openConnection(); } openConnection() { if (typeof EventSource === 'undefined') { this.logger.warn('StreamingConnection not opened. EventSource is not available.'); return; } this.connection = new EventSource(this.url, { withCredentials: true }); this.connection.onmessage = (event) => { this.onMessage(event.data); }; this.connection.onerror = () => { this.logger.warn('StreamingConnection warning. Connection failed to establish.'); }; this.connection.onopen = () => { this.logger.debug('StreamingConnection opened'); }; } isConnected() { var _a, _b; return ((_a = this.connection) === null || _a === void 0 ? void 0 : _a.readyState) === ((_b = this.connection) === null || _b === void 0 ? void 0 : _b.OPEN); } reopen() { if (!this.isConnected()) { this.close(); this.openConnection(); } } close() { var _a; (_a = this.connection) === null || _a === void 0 ? void 0 : _a.close(); } } class HookContext { constructor(user, variableKey, defaultValue, metadata, evaluationContext) { this.user = user; this.variableKey = variableKey; this.defaultValue = defaultValue; this.metadata = metadata; this.evaluationContext = evaluationContext; } } class EvalHooksRunner { constructor(hooks = [], logger) { this.hooks = hooks; this.logger = logger; } runHooksForEvaluation(user, key, defaultValue, resolver) { const context = new HookContext(user !== null && user !== void 0 ? user : {}, key, defaultValue, {}); const savedHooks = [...this.hooks]; const reversedHooks = [...savedHooks].reverse(); let variable; try { const frozenContext = Object.freeze({ ...context, }); this.runBefore(savedHooks, frozenContext); variable = resolver.call(frozenContext); const evaluationContext = { key, value: variable.value, isDefaulted: variable.isDefaulted, eval: variable.eval, }; context.evaluationContext = evaluationContext; this.runAfter(reversedHooks, context); } catch (error) { this.runError(reversedHooks, context, error); this.runFinally(reversedHooks, context); throw error; } this.runFinally(reversedHooks, context); return variable; } runBefore(hooks, context) { for (const hook of hooks) { hook.before(context); } } runAfter(hooks, context) { for (const hook of hooks) { hook.after(context); } } runFinally(hooks, context) { var _a; try { for (const hook of hooks) { hook.onFinally(context); } } catch (error) { (_a = this.logger) === null || _a === void 0 ? void 0 : _a.error('Error running before hooks', error); } } runError(hooks, context, error) { var _a; try { for (const hook of hooks) { hook.error(context, error); } } catch (error) { (_a = this.logger) === null || _a === void 0 ? void 0 : _a.error('Error running error hooks', error); } } enqueue(hook) { this.hooks.push(hook); } clear() { this.hooks = []; } } class DevCycleClient { get isInitialized() { return this._isInitialized; } constructor(sdkKey, user, options = {}) { var _a; this._isInitialized = false; this.userSaved = false; this._closing = false; this.isConfigCached = false; this.initializeTriggered = false; /** * Logic to initialize the client with the appropriate user and configuration data by making requests to DevCycle * and loading from local storage. This either happens immediately on client initialization, or when the user is * first identified (in deferred mode) * @param initialUser */ this.clientInitialization = async (initialUser) => { if (this.initializeTriggered || this._closing) { return this; } this.initializeTriggered = true; // don't wait to load anon id if we're being provided with a real one const storedAnonymousId = initialUser.user_id ? undefined : await this.store.loadAnonUserId(); this.user = new DVCPopulatedUser(initialUser, this.options, undefined, storedAnonymousId, undefined, this.store); if (!this.options.bootstrapConfig) { await this.getConfigCache(this.user); } // set up requestConsolidator and hook up callback methods this.requestConsolidator = new ConfigRequestConsolidator((user, extraParams) => getConfigJson(this.sdkKey, user, this.logger, this.options, extraParams), (config, user) => this.handleConfigReceived(config, user), th