@devcycle/js-client-sdk
Version:
The Javascript Client SDK for DevCycle
1,314 lines (1,292 loc) • 77.7 kB
JavaScript
'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