@devcycle/js-client-sdk
Version:
The Javascript Client SDK for DevCycle
1,302 lines (1,285 loc) • 64.5 kB
JavaScript
import { UserError, getVariableTypeFromValue } from '@devcycle/types';
export { UserError } from '@devcycle/types';
import fetchWithRetry from 'fetch-retry';
import chunk from 'lodash/chunk';
import { v4 } from 'uuid';
import UAParser from 'ua-parser-js';
import isNumber from 'lodash/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(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 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(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 || 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(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(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 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 = 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: 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 = new Promise((resolve, reject) => {
var _a;
(_a = this.eventQueue) === null || _a === void 0 ? void 0 : _a.flushEvents();
this.onInitialized
.then(() => this.store.loadAnonUserId())
.then(async (cachedAnonId) => {
await this.store.removeAnonUserId();
oldAnonymousId = cachedAnonId;
return;
})
.then(() => this.requestConsolidator.queue(anonUser))
.then(async (config) => {