UNPKG

@devcycle/js-client-sdk

Version:

The Javascript Client SDK for DevCycle

1,300 lines (1,280 loc) 65.3 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var types$1 = require('@devcycle/types'); var fetchWithRetry = require('fetch-retry'); var chunk = require('lodash/chunk'); 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 fetchWithRetry__default = /*#__PURE__*/_interopDefaultLegacy(fetchWithRetry); var chunk__default = /*#__PURE__*/_interopDefaultLegacy(chunk); 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', }; // 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; } }; 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 || null, 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(); } } class DVCRequestEvent { constructor(event, user_id, featureVars) { 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; this.featureVars = featureVars || {}; this.metaData = metaData; } } 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}`); } }; function generateEventPayload(config, user, events) { return { events: events.map((event) => { return new DVCRequestEvent(event, user.user_id, config === null || config === void 0 ? void 0 : config.featureVariationMap); }), user, }; } // 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.evalReason = variable.evalReason; } onUpdate(callback) { checkParamType('callback', callback, 'function'); this.callback = callback; return this; } } class CacheStore { constructor(storage, logger) { this.store = storage; this.logger = logger; } getConfigKey(user) { return user.isAnonymous ? StoreKey.AnonymousConfig : StoreKey.IdentifiedConfig; } getConfigUserIdKey(user) { return `${this.getConfigKey(user)}.user_id`; } getConfigFetchDateKey(user) { return `${this.getConfigKey(user)}.fetch_date`; } async loadConfigUserId(user) { const userIdKey = this.getConfigUserIdKey(user); return await this.store.load(userIdKey); } async loadConfigFetchDate(user) { const fetchDateKey = this.getConfigFetchDateKey(user); const fetchDate = (await this.store.load(fetchDateKey)) || '0'; return parseInt(fetchDate, 10); } async saveConfig(data, user, dateFetched) { var _a; const configKey = this.getConfigKey(user); const fetchDateKey = this.getConfigFetchDateKey(user); const userIdKey = this.getConfigUserIdKey(user); await Promise.all([ this.store.save(configKey, data), this.store.save(fetchDateKey, dateFetched), this.store.save(userIdKey, user.user_id), ]); (_a = this.logger) === null || _a === void 0 ? void 0 : _a.info('Successfully saved config 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, configCacheTTL = 604800000) { var _a, _b, _c, _d; const userId = await this.loadConfigUserId(user); if (user.user_id !== userId) { (_a = this.logger) === null || _a === void 0 ? void 0 : _a.debug(`Skipping cached config: no config for user ID ${user.user_id}`); return null; } const cachedFetchDate = await this.loadConfigFetchDate(user); const isConfigCacheTTLExpired = Date.now() - cachedFetchDate > configCacheTTL; if (isConfigCacheTTLExpired) { (_b = this.logger) === null || _b === void 0 ? void 0 : _b.debug('Skipping cached config: last fetched date is too old'); return null; } const configKey = await this.getConfigKey(user); const config = await this.store.load(configKey); if (config === null || config === undefined) { (_c = this.logger) === null || _c === void 0 ? void 0 : _c.debug('Skipping cached config: no config found'); return null; } if (!this.isBucketedUserConfig(config)) { (_d = this.logger) === null || _d === void 0 ? void 0 : _d.debug(`Skipping cached config: invalid config found: ${JSON.stringify(config)}`); return null; } return config; } async saveUser(user) { var _a; if (!user) { throw new Error('No user to save'); } await this.store.save(StoreKey.User, user); (_a = this.logger) === null || _a === void 0 ? void 0 : _a.info('Successfully saved user to local storage'); } async loadUser() { return await this.store.load(StoreKey.User); } 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() { await this.store.remove(StoreKey.AnonUserId); } } 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); } } 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); } } IndexedDBStrategy.storeName = 'DevCycleStore'; IndexedDBStrategy.DBName = 'DevCycleDB'; function getStorageStrategy() { if (checkIsServiceWorker()) { return new IndexedDBStrategy(); } else { return new LocalStorageStrategy(typeof window === 'undefined'); } } var name = "@devcycle/js-client-sdk"; var version = "1.37.0"; 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.0.0" }; var dependencies = { "@devcycle/types": "^1.24.0", "fetch-retry": "^5.0.6", lodash: "^4.17.21", "ua-parser-js": "^1.0.36", 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 { constructor(user, options, staticData, anonymousUserId, headerUserAgent) { var _a; if (((_a = user.user_id) === null || _a === void 0 ? void 0 : _a.trim()) === '') { throw new Error('A User cannot be created with a user_id that is an empty string'); } this.user_id = user.isAnonymous || !user.user_id ? user.user_id || anonymousUserId || uuid.v4() : user.user_id; this.isAnonymous = user.isAnonymous || !user.user_id; 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 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 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); 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, Date.now()), this.user); try { if (!this.options.bootstrapConfig) { await this.requestConsolidator.queue(this.user); } else { this.handleConfigReceived(this.options.bootstrapConfig, this.user, Date.now()); } this._isInitialized = true; this.settleOnInitialized(this); this.logger.info('Client initialized'); } catch (err) { this.initializeOnConfigFailure(this.user, err); return this; } this.eventEmitter.emitInitialized(true); if (this.user.isAnonymous) { void this.store.saveAnonUserId(this.user.user_id); } else { void this.store.removeAnonUserId(); } return this; }; /** * Complete initialization process without config so that we can return default values */ this.initializeOnConfigFailure = (user, err) => { if (this.isInitialized) { return; } this.eventEmitter.emitInitialized(false); if (err) { this.eventEmitter.emitError(err); } void this.setUser(user); this.settleOnInitialized(this, err instanceof types$1.UserError ? err : null); }; if (!options.sdkPlatform) { options.sdkPlatform = 'js'; } if ((_a = options.next) === null || _a === void 0 ? void 0 : _a.configRefreshHandler) { this.configRefetchHandler = options.next.configRefreshHandler; } this.logger = options.logger || dvcDefaultLogger({ level: options.logLevel }); this.store = new CacheStore(options.storage || getStorageStrategy(), this.logger); this.options = options; this.sdkKey = sdkKey; this.variableDefaultMap = {}; if (!(this.options.disableAutomaticEventLogging && this.options.disableCustomEventLogging)) { this.eventQueue = new EventQueue(sdkKey, this, options); } this.eventEmitter = new EventEmitter(); if (!this.options.disableRealtimeUpdates) { this.registerVisibilityChangeHandler(); } this.onInitialized = new Promise((resolve, reject) => { this.settleOnInitialized = (value, error) => { if (error) { this._isInitialized = false; reject(error); } else { this._isInitialized = true; resolve(value); } }; }); if (!this.options.deferInitialization) { if (!user) { throw new Error('User must be provided to initialize SDK'); } void this.clientInitialization(user); } else if (this.options.bootstrapConfig) { throw new Error('bootstrapConfig option can not be combined with deferred initialization!'); } if (!(options === null || options === void 0 ? void 0 : options.reactNative) && typeof window !== 'undefined') { this.windowMessageHandler = (event) => { const message = event.data; if ((message === null || message === void 0 ? void 0 : message.type) === 'DVC.optIn.saved') { this.refetchConfig(false); } }; window.addEventListener('message', this.windowMessageHandler); this.windowPageHideHandler = () => { this.flushEvents(); }; window.addEventListener('pagehide', this.windowPageHideHandler); } } onClientInitialized(onInitialized) { if (onInitialized && typeof onInitialized === 'function') { this.onInitialized .then(() => onInitialized(null, this)) .catch((err) => onInitialized(err)); return; } return this.onInitialized; } /** * Get variable object associated with Features. Use the variable's key to fetch the DVCVariable object. * If the user does not receive the feature, the default value is used in the returned DVCVariable object. * DVCVariable is returned, which has a `value` property that is used to grab the variable value, * and a convenience method to pass in a callback to notify the user when the value has changed from the server. * * @param key * @param defaultValue */ variable(key, defaultValue) { var _a, _b; if (defaultValue === undefined || defaultValue === null) { throw new Error('Default value is a required param'); } // this will throw if type is invalid const type = types$1.getVariableTypeFromValue(defaultValue, key, this.logger, true); const defaultValueKey = typeof defaultValue === 'string' ? defaultValue : JSON.stringify(defaultValue); let variable; if (this.variableDefaultMap[key] && this.variableDefaultMap[key][defaultValueKey]) { variable = this.variableDefaultMap[key][defaultValueKey]; } else { const configVariable = (_b = (_a = this.config) === null || _a === void 0 ? void 0 : _a.variables) === null || _b === void 0 ? void 0 : _b[key]; const data = { key, defaultValue, }; if (configVariable) { if (configVariable.type === type) { data.value = configVariable.value; data.evalReason = configVariable.evalReason; } else { this.logger.warn(`Type mismatch for variable ${key}. Expected ${type}, got ${configVariable.type}`); } } variable = new DVCVariable(data); this.variableDefaultMap[key] = { [defaultValueKey]: variable, ...this.variableDefaultMap[key], }; } this.trackVariableEvaluated(variable); this.eventEmitter.emitVariableEvaluated(variable); return variable; } trackVariableEvaluated(variable) { var _a, _b, _c; if (this.options.disableAutomaticEventLogging) return; try { const variableFromConfig = (_b = (_a = this.config) === null || _a === void 0 ? void 0 : _a.variables) === null || _b === void 0 ? void 0 : _b[variable.key]; (_c = this.eventQueue) === null || _c === void 0 ? void 0 : _c.queueAggregateEvent({ type: variable.isDefaulted ? EventTypes.variableDefaulted : EventTypes.variableEvaluated, target: variable.key, metaData: { value: variable.value, type: types$1.getVariableTypeFromValue(variable.defaultValue, variable.key, this.logger), _variable: variableFromConfig === null || variableFromConfig === void 0 ? void 0 : variableFromConfig._id, }, }); } catch (e) { this.eventEmitter.emitError(e); this.logger.warn(`Error with queueing aggregate events ${e}`); } } /** * Get a variable's value associated with a Feature. Use the variable's key to fetch the variable's value. * If the user is not segmented into the feature, the default value is returned. * * @param key * @param defaultValue */ variableValue(key, defaultValue) { return this.variable(key, defaultValue).value; } identifyUser(user, callback) { if (this.options.next) { this.logger.error('Unable to change user identity from the clientside in Next.js'); return; } const promise = this._identifyUser(user); if (callback && typeof callback == 'function') { promise .then((variables) => callback(null, variables)) .catch((err) => callback(err, null)); return; } return promise; } async _identifyUser(user) { var _a, _b; let updatedUser; if (this.options.deferInitialization && !this.initializeTriggered) { await this.clientInitialization(user); return ((_a = this.config) === null || _a === void 0 ? void 0 : _a.variables) || {}; } void ((_b = this.eventQueue) === null || _b === void 0 ? void 0 : _b.flushEvents()); try { await this.onInitialized; const storedAnonymousId = await this.store.loadAnonUserId(); if (this.user && user.user_id === this.user.user_id) { updatedUser = this.user.updateUser(user, this.options); } else { updatedUser = new DVCPopulatedUser(user, this.options, undefined, storedAnonymousId); } const config = await this.requestConsolidator.queue(updatedUser); if (user.isAnonymous || !user.user_id) { await this.store.saveAnonUserId(updatedUser.user_id); } return config.variables || {}; } catch (err) { this.eventEmitter.emitError(err); throw err; } } resetUser(callback) { if (this.options.next) { this.logger.error('Unable to change user identity from the clientside in Next.js'); return; } let oldAnonymousId; const anonUser = new DVCPopulatedUser({ isAnonymous: true }, this.options); const promise