@launchdarkly/js-sdk-common
Version:
LaunchDarkly SDK for JavaScript - common code
1,315 lines (1,304 loc) • 122 kB
JavaScript
/**
* Converts a literal to a ref string.
* @param value
* @returns An escaped literal which can be used as a ref.
*/
function toRefString(value) {
return `/${value.replace(/~/g, '~0').replace(/\//g, '~1')}`;
}
/**
* Produce a literal from a ref component.
* @param ref
* @returns A literal version of the ref.
*/
function unescape(ref) {
return ref.indexOf('~') ? ref.replace(/~1/g, '/').replace(/~0/g, '~') : ref;
}
function getComponents(reference) {
const referenceWithoutPrefix = reference.startsWith('/') ? reference.substring(1) : reference;
return referenceWithoutPrefix.split('/').map((component) => unescape(component));
}
function isLiteral(reference) {
return !reference.startsWith('/');
}
function validate(reference) {
return !reference.match(/\/\/|(^\/.*~[^0|^1])|~$/);
}
class AttributeReference {
/**
* Take an attribute reference string, or literal string, and produce
* an attribute reference.
*
* Legacy user objects would have been created with names not
* references. So, in that case, we need to use them as a component
* without escaping them.
*
* e.g. A user could contain a custom attribute of `/a` which would
* become the literal `a` if treated as a reference. Which would cause
* it to no longer be redacted.
* @param refOrLiteral The attribute reference string or literal string.
* @param literal it true the value should be treated as a literal.
*/
constructor(refOrLiteral, literal = false) {
if (!literal) {
this.redactionName = refOrLiteral;
if (refOrLiteral === '' || refOrLiteral === '/' || !validate(refOrLiteral)) {
this.isValid = false;
this._components = [];
return;
}
if (isLiteral(refOrLiteral)) {
this._components = [refOrLiteral];
}
else if (refOrLiteral.indexOf('/', 1) < 0) {
this._components = [unescape(refOrLiteral.slice(1))];
}
else {
this._components = getComponents(refOrLiteral);
}
// The items inside of '_meta' are not intended to be addressable.
// Excluding it as a valid reference means that we can make it non-addressable
// without having to copy all the attributes out of the context object
// provided by the user.
if (this._components[0] === '_meta') {
this.isValid = false;
}
else {
this.isValid = true;
}
}
else {
const literalVal = refOrLiteral;
this._components = [literalVal];
this.isValid = literalVal !== '';
// Literals which start with '/' need escaped to prevent ambiguity.
this.redactionName = literalVal.startsWith('/') ? toRefString(literalVal) : literalVal;
}
}
get(target) {
const { _components: components, isValid } = this;
if (!isValid) {
return undefined;
}
let current = target;
// This doesn't use a range based for loops, because those use generators.
// See `no-restricted-syntax`.
// It also doesn't use a collection method because this logic is more
// straightforward with a loop.
for (let index = 0; index < components.length; index += 1) {
const component = components[index];
if (current !== null &&
current !== undefined &&
// See https://eslint.org/docs/rules/no-prototype-builtins
Object.prototype.hasOwnProperty.call(current, component) &&
typeof current === 'object' &&
// We do not want to allow indexing into an array.
!Array.isArray(current)) {
current = current[component];
}
else {
return undefined;
}
}
return current;
}
getComponent(depth) {
return this._components[depth];
}
get depth() {
return this._components.length;
}
get isKind() {
return this._components.length === 1 && this._components[0] === 'kind';
}
compare(other) {
return (this.depth === other.depth &&
this._components.every((value, index) => value === other.getComponent(index)));
}
get components() {
return [...this._components];
}
}
/**
* For use as invalid references when deserializing Flag/Segment data.
*/
AttributeReference.InvalidReference = new AttributeReference('');
/* eslint-disable class-methods-use-this */
/* eslint-disable max-classes-per-file */
/**
* Validate a factory or instance.
*/
class FactoryOrInstance {
is(factoryOrInstance) {
if (Array.isArray(factoryOrInstance)) {
return false;
}
const anyFactory = factoryOrInstance;
const typeOfFactory = typeof anyFactory;
return typeOfFactory === 'function' || typeOfFactory === 'object';
}
getType() {
return 'factory method or object';
}
}
/**
* Validate a basic type.
*/
class Type {
constructor(typeName, example) {
this._typeName = typeName;
this.typeOf = typeof example;
}
is(u) {
if (Array.isArray(u)) {
return false;
}
return typeof u === this.typeOf;
}
getType() {
return this._typeName;
}
}
/**
* Validate an array of the specified type.
*
* This does not validate instances of types. All class instances
* of classes will simply objects.
*/
class TypeArray {
constructor(typeName, example) {
this._typeName = typeName;
this.typeOf = typeof example;
}
is(u) {
if (Array.isArray(u)) {
if (u.length > 0) {
return u.every((val) => typeof val === this.typeOf);
}
return true;
}
return false;
}
getType() {
return this._typeName;
}
}
/**
* Validate a value is a number and is greater or eval than a minimum.
*/
class NumberWithMinimum extends Type {
constructor(min) {
super(`number with minimum value of ${min}`, 0);
this.min = min;
}
is(u) {
return typeof u === this.typeOf && u >= this.min;
}
}
/**
* Validate a value is a string and it matches the given expression.
*/
class StringMatchingRegex extends Type {
constructor(expression) {
super(`string matching ${expression}`, '');
this.expression = expression;
}
is(u) {
return typeof u === 'string' && !!u.match(this.expression);
}
}
/**
* Validate a value is a function.
*/
class Function {
is(u) {
// We cannot inspect the parameters and there isn't really
// a generic function type we can instantiate.
// So the type guard is here just to make TS comfortable
// calling something after using this guard.
return typeof u === 'function';
}
getType() {
return 'function';
}
}
class NullableBoolean {
is(u) {
return typeof u === 'boolean' || typeof u === 'undefined' || u === null;
}
getType() {
return 'boolean | undefined | null';
}
}
// Our reference SDK, Go, parses date/time strings with the time.RFC3339Nano format.
// This regex should match strings that are valid in that format, and no others.
// Acceptable:
// 2019-10-31T23:59:59Z, 2019-10-31T23:59:59.100Z,
// 2019-10-31T23:59:59-07, 2019-10-31T23:59:59-07:00, etc.
// Unacceptable: no "T", no time zone designation
const DATE_REGEX = /^\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d\d*)?(Z|[-+]\d\d(:\d\d)?)/;
/**
* Validate a value is a date. Values which are numbers are treated as dates and any string
* which if compliant with `time.RFC3339Nano` is a date.
*/
class DateValidator {
is(u) {
return typeof u === 'number' || (typeof u === 'string' && DATE_REGEX.test(u));
}
getType() {
return 'date';
}
}
/**
* Validates that a string is a valid kind.
*/
class KindValidator extends StringMatchingRegex {
constructor() {
super(/^(\w|\.|-)+$/);
}
is(u) {
return super.is(u) && u !== 'kind';
}
}
/**
* A set of standard type validators.
*/
class TypeValidators {
static createTypeArray(typeName, example) {
return new TypeArray(typeName, example);
}
static numberWithMin(min) {
return new NumberWithMinimum(min);
}
static stringMatchingRegex(expression) {
return new StringMatchingRegex(expression);
}
}
TypeValidators.String = new Type('string', '');
TypeValidators.Number = new Type('number', 0);
TypeValidators.ObjectOrFactory = new FactoryOrInstance();
TypeValidators.Object = new Type('object', {});
TypeValidators.StringArray = new TypeArray('string[]', '');
TypeValidators.Boolean = new Type('boolean', true);
TypeValidators.Function = new Function();
TypeValidators.Date = new DateValidator();
TypeValidators.Kind = new KindValidator();
TypeValidators.NullableBoolean = new NullableBoolean();
/**
* Check if a context is a single kind context.
* @param context
* @returns true if the context is a single kind context.
*/
function isSingleKind(context) {
if ('kind' in context) {
return TypeValidators.String.is(context.kind) && context.kind !== 'multi';
}
return false;
}
/**
* Check if a context is a multi-kind context.
* @param context
* @returns true if it is a multi-kind context.
*/
function isMultiKind(context) {
if ('kind' in context) {
return TypeValidators.String.is(context.kind) && context.kind === 'multi';
}
return false;
}
/**
* Check if a context is a legacy user context.
* @param context
* @returns true if it is a legacy user context.
*/
function isLegacyUser(context) {
return !('kind' in context) || context.kind === null || context.kind === undefined;
}
/**
* Given some object to serialize product a canonicalized JSON string.
* https://www.rfc-editor.org/rfc/rfc8785.html
*
* We do not support custom toJSON methods on objects. Objects should be limited to basic types.
*
* @param object The object to serialize.
*/
function canonicalize(object, visited = []) {
// For JavaScript the default JSON serialization will produce canonicalized output for basic types.
if (object === null || typeof object !== 'object') {
return JSON.stringify(object);
}
if (visited.includes(object)) {
throw new Error('Cycle detected');
}
if (Array.isArray(object)) {
const values = object
.map((item) => canonicalize(item, [...visited, object]))
.map((item) => (item === undefined ? 'null' : item));
return `[${values.join(',')}]`;
}
const values = Object.keys(object)
.sort()
.map((key) => {
const value = canonicalize(object[key], [...visited, object]);
if (value !== undefined) {
return `${JSON.stringify(key)}:${value}`;
}
return undefined;
})
.filter((item) => item !== undefined);
return `{${values.join(',')}}`;
}
// The general strategy for the context is to transform the passed in context
// as little as possible. We do convert the legacy users to a single kind
// context, but we do not translate all passed contexts into a rigid structure.
// The context will have to be copied for events, but we want to avoid any
// copying that we can.
// So we validate that the information we are given is correct, and then we
// just proxy calls with a nicely typed interface.
// This is to reduce work on the hot-path. Later, for event processing, deeper
// cloning of the context will be done.
// When no kind is specified, then this kind will be used.
const DEFAULT_KIND = 'user';
// The API allows for calling with an `LDContext` which is
// `LDUser | LDSingleKindContext | LDMultiKindContext`. When ingesting a context
// first the type must be determined to allow us to put it into a consistent type.
/**
* The partial URL encoding is needed because : is a valid character in context keys.
*
* Partial encoding is the replacement of all colon (:) characters with the URL
* encoded equivalent (%3A) and all percent (%) characters with the URL encoded
* equivalent (%25).
* @param key The key to encode.
* @returns Partially URL encoded key.
*/
function encodeKey(key) {
if (key.includes('%') || key.includes(':')) {
return key.replace(/%/g, '%25').replace(/:/g, '%3A');
}
return key;
}
/**
* Check if the given value is a LDContextCommon.
* @param kindOrContext
* @returns true if it is an LDContextCommon
*
* Due to a limitation in the expressiveness of these highly polymorphic types any field
* in a multi-kind context can either be a context or 'kind'. So we need to re-assure
* the compiler that it isn't the word multi.
*
* Because we do not allow top level values in a multi-kind context we can validate
* that as well.
*/
function isContextCommon(kindOrContext) {
return kindOrContext && TypeValidators.Object.is(kindOrContext);
}
/**
* Validate a context kind.
* @param kind
* @returns true if the kind is valid.
*/
function validKind(kind) {
return TypeValidators.Kind.is(kind);
}
/**
* Validate a context key.
* @param key
* @returns true if the key is valid.
*/
function validKey(key) {
return TypeValidators.String.is(key) && key !== '';
}
function processPrivateAttributes(privateAttributes, literals = false) {
if (privateAttributes) {
return privateAttributes.map((privateAttribute) => new AttributeReference(privateAttribute, literals));
}
return [];
}
function defined(value) {
return value !== null && value !== undefined;
}
/**
* Convert a legacy user to a single kind context.
* @param user
* @returns A single kind context.
*/
function legacyToSingleKind(user) {
const singleKindContext = {
// Key was coerced to a string for eval and events, so we can do that up-front.
...(user.custom || []),
kind: 'user',
key: String(user.key),
};
// For legacy users we never established a difference between null
// and undefined for inputs. Because anonymous can be used in evaluations
// we would want it to not possibly match true/false unless defined.
// Which is different than coercing a null/undefined anonymous as `false`.
if (defined(user.anonymous)) {
const anonymous = !!user.anonymous;
delete singleKindContext.anonymous;
singleKindContext.anonymous = anonymous;
}
if (user.name !== null && user.name !== undefined) {
singleKindContext.name = user.name;
}
if (user.ip !== null && user.ip !== undefined) {
singleKindContext.ip = user.ip;
}
if (user.firstName !== null && user.firstName !== undefined) {
singleKindContext.firstName = user.firstName;
}
if (user.lastName !== null && user.lastName !== undefined) {
singleKindContext.lastName = user.lastName;
}
if (user.email !== null && user.email !== undefined) {
singleKindContext.email = user.email;
}
if (user.avatar !== null && user.avatar !== undefined) {
singleKindContext.avatar = user.avatar;
}
if (user.country !== null && user.country !== undefined) {
singleKindContext.country = user.country;
}
if (user.privateAttributeNames !== null && user.privateAttributeNames !== undefined) {
singleKindContext._meta = {
privateAttributes: user.privateAttributeNames,
};
}
// We are not pulling private attributes over because we will serialize
// those from attribute references for events.
return singleKindContext;
}
/**
* Container for a context/contexts. Because contexts come from external code
* they must be thoroughly validated and then formed to comply with
* the type system.
*/
class Context {
/**
* Contexts should be created using the static factory method {@link Context.fromLDContext}.
* @param kind The kind of the context.
*
* The factory methods are static functions within the class because they access private
* implementation details, so they cannot be free functions.
*/
constructor(valid, kind, message) {
this._isMulti = false;
this._isUser = false;
this._wasLegacy = false;
this._contexts = {};
this.kind = kind;
this.valid = valid;
this.message = message;
}
static _contextForError(kind, message) {
return new Context(false, kind, message);
}
static _getValueFromContext(reference, context) {
if (!context || !reference.isValid) {
return undefined;
}
if (reference.depth === 1 && reference.getComponent(0) === 'anonymous') {
return !!context?.anonymous;
}
return reference.get(context);
}
_contextForKind(kind) {
if (this._isMulti) {
return this._contexts[kind];
}
if (this.kind === kind) {
return this._context;
}
return undefined;
}
static _fromMultiKindContext(context) {
const kinds = Object.keys(context).filter((key) => key !== 'kind');
const kindsValid = kinds.every(validKind);
if (!kinds.length) {
return Context._contextForError('multi', 'A multi-kind context must contain at least one kind');
}
if (!kindsValid) {
return Context._contextForError('multi', 'Context contains invalid kinds');
}
const privateAttributes = {};
let contextsAreObjects = true;
const contexts = kinds.reduce((acc, kind) => {
const singleContext = context[kind];
if (isContextCommon(singleContext)) {
acc[kind] = singleContext;
privateAttributes[kind] = processPrivateAttributes(singleContext._meta?.privateAttributes);
}
else {
// No early break isn't the most efficient, but it is an error condition.
contextsAreObjects = false;
}
return acc;
}, {});
if (!contextsAreObjects) {
return Context._contextForError('multi', 'Context contained contexts that were not objects');
}
if (!Object.values(contexts).every((part) => validKey(part.key))) {
return Context._contextForError('multi', 'Context contained invalid keys');
}
// There was only a single kind in the multi-kind context.
// So we can just translate this to a single-kind context.
if (kinds.length === 1) {
const kind = kinds[0];
const created = new Context(true, kind);
created._context = { ...contexts[kind], kind };
created._privateAttributeReferences = privateAttributes;
created._isUser = kind === 'user';
return created;
}
const created = new Context(true, context.kind);
created._contexts = contexts;
created._privateAttributeReferences = privateAttributes;
created._isMulti = true;
return created;
}
static _fromSingleKindContext(context) {
const { key, kind } = context;
const kindValid = validKind(kind);
const keyValid = validKey(key);
if (!kindValid) {
return Context._contextForError(kind ?? 'unknown', 'The kind was not valid for the context');
}
if (!keyValid) {
return Context._contextForError(kind, 'The key for the context was not valid');
}
// The JSON interfaces uses dangling _.
// eslint-disable-next-line no-underscore-dangle
const privateAttributeReferences = processPrivateAttributes(context._meta?.privateAttributes);
const created = new Context(true, kind);
created._isUser = kind === 'user';
created._context = context;
created._privateAttributeReferences = {
[kind]: privateAttributeReferences,
};
return created;
}
static _fromLegacyUser(context) {
const keyValid = context.key !== undefined && context.key !== null;
// For legacy users we allow empty keys.
if (!keyValid) {
return Context._contextForError('user', 'The key for the context was not valid');
}
const created = new Context(true, 'user');
created._isUser = true;
created._wasLegacy = true;
created._context = legacyToSingleKind(context);
created._privateAttributeReferences = {
user: processPrivateAttributes(context.privateAttributeNames, true),
};
return created;
}
/**
* Attempt to create a {@link Context} from an {@link LDContext}.
* @param context The input context to create a Context from.
* @returns a {@link Context}, if the context was not valid, then the returned contexts `valid`
* property will be false.
*/
static fromLDContext(context) {
if (!context) {
return Context._contextForError('unknown', 'No context specified. Returning default value');
}
if (isSingleKind(context)) {
return Context._fromSingleKindContext(context);
}
if (isMultiKind(context)) {
return Context._fromMultiKindContext(context);
}
if (isLegacyUser(context)) {
return Context._fromLegacyUser(context);
}
return Context._contextForError('unknown', 'Context was not of a valid kind');
}
/**
* Creates a {@link LDContext} from a {@link Context}.
* @param context to be converted
* @returns an {@link LDContext} if input was valid, otherwise undefined
*/
static toLDContext(context) {
if (!context.valid) {
return undefined;
}
const contexts = context.getContexts();
if (!context._isMulti) {
return contexts[0][1];
}
const result = {
kind: 'multi',
};
contexts.forEach((kindAndContext) => {
const kind = kindAndContext[0];
const nestedContext = kindAndContext[1];
result[kind] = nestedContext;
});
return result;
}
/**
* Attempt to get a value for the given context kind using the given reference.
* @param reference The reference to the value to get.
* @param kind The kind of the context to get the value for.
* @returns a value or `undefined` if one is not found.
*/
valueForKind(reference, kind = DEFAULT_KIND) {
if (reference.isKind) {
return this.kinds;
}
return Context._getValueFromContext(reference, this._contextForKind(kind));
}
/**
* Attempt to get a key for the specified kind.
* @param kind The kind to get a key for.
* @returns The key for the specified kind, or undefined.
*/
key(kind = DEFAULT_KIND) {
return this._contextForKind(kind)?.key;
}
/**
* True if this is a multi-kind context.
*/
get isMultiKind() {
return this._isMulti;
}
/**
* Get the canonical key for this context.
*/
get canonicalKey() {
if (this._isUser) {
return this._context.key;
}
if (this._isMulti) {
return Object.keys(this._contexts)
.sort()
.map((key) => `${key}:${encodeKey(this._contexts[key].key)}`)
.join(':');
}
return `${this.kind}:${encodeKey(this._context.key)}`;
}
/**
* Get the kinds of this context.
*/
get kinds() {
if (this._isMulti) {
return Object.keys(this._contexts);
}
return [this.kind];
}
/**
* Get the kinds, and their keys, for this context.
*/
get kindsAndKeys() {
if (this._isMulti) {
return Object.entries(this._contexts).reduce((acc, [kind, context]) => {
acc[kind] = context.key;
return acc;
}, {});
}
return { [this.kind]: this._context.key };
}
/**
* Get the attribute references.
*
* @param kind
*/
privateAttributes(kind) {
return this._privateAttributeReferences?.[kind] || [];
}
/**
* Get the underlying context objects from this context.
*
* This method is intended to be used in event generation.
*
* The returned objects should not be modified.
*/
getContexts() {
if (this._isMulti) {
return Object.entries(this._contexts);
}
return [[this.kind, this._context]];
}
get legacy() {
return this._wasLegacy;
}
/**
* Get the serialized canonical JSON for this context. This is not filtered for use in events.
*
* This method will cache the result.
*
* @returns The serialized canonical JSON or undefined if it cannot be serialized.
*/
canonicalUnfilteredJson() {
if (!this.valid) {
return undefined;
}
if (this._cachedCanonicalJson) {
return this._cachedCanonicalJson;
}
try {
this._cachedCanonicalJson = canonicalize(Context.toLDContext(this));
}
catch {
// Indicated by undefined being returned.
}
return this._cachedCanonicalJson;
}
}
Context.UserKind = DEFAULT_KIND;
// _meta is part of the specification.
// These attributes cannot be removed via a private attribute.
const protectedAttributes = ['key', 'kind', '_meta', 'anonymous'].map((str) => new AttributeReference(str, true));
// Attributes that should be stringified for legacy users.
const legacyTopLevelCopyAttributes = [
'name',
'ip',
'firstName',
'lastName',
'email',
'avatar',
'country',
];
function compare(a, b) {
return a.depth === b.length && b.every((value, index) => value === a.getComponent(index));
}
function cloneWithRedactions(target, references) {
const stack = [];
const cloned = {};
const excluded = [];
stack.push(...Object.keys(target).map((key) => ({
key,
ptr: [key],
source: target,
parent: cloned,
visited: [target],
})));
while (stack.length) {
const item = stack.pop();
const redactRef = references.find((ref) => compare(ref, item.ptr));
if (!redactRef) {
const value = item.source[item.key];
// Handle null because it overlaps with object, which we will want to handle later.
if (value === null) {
item.parent[item.key] = value;
}
else if (Array.isArray(value)) {
item.parent[item.key] = [...value];
}
else if (typeof value === 'object') {
// Arrays and null must already be handled.
// Prevent cycles by not visiting the same object
// with in the same branch. Different branches
// may contain the same object.
//
// Same object visited twice in different branches.
// A -> B -> D
// -> C -> D
// This is fine, which is why it doesn't just check if the object
// was visited ever.
if (!item.visited.includes(value)) {
item.parent[item.key] = {};
stack.push(...Object.keys(value).map((key) => ({
key,
ptr: [...item.ptr, key],
source: value,
parent: item.parent[item.key],
visited: [...item.visited, value],
})));
}
}
else {
item.parent[item.key] = value;
}
}
else {
excluded.push(redactRef.redactionName);
}
}
return { cloned, excluded: excluded.sort() };
}
class ContextFilter {
constructor(_allAttributesPrivate, _privateAttributes) {
this._allAttributesPrivate = _allAttributesPrivate;
this._privateAttributes = _privateAttributes;
}
filter(context, redactAnonymousAttributes = false) {
const contexts = context.getContexts();
if (contexts.length === 1) {
return this._filterSingleKind(context, contexts[0][1], contexts[0][0], redactAnonymousAttributes);
}
const filteredMulti = {
kind: 'multi',
};
contexts.forEach(([kind, single]) => {
filteredMulti[kind] = this._filterSingleKind(context, single, kind, redactAnonymousAttributes);
});
return filteredMulti;
}
_getAttributesToFilter(context, single, kind, redactAllAttributes) {
return (redactAllAttributes
? Object.keys(single).map((k) => new AttributeReference(k, true))
: [...this._privateAttributes, ...context.privateAttributes(kind)]).filter((attr) => !protectedAttributes.some((protectedAttr) => protectedAttr.compare(attr)));
}
_filterSingleKind(context, single, kind, redactAnonymousAttributes) {
const redactAllAttributes = this._allAttributesPrivate || (redactAnonymousAttributes && single.anonymous === true);
const { cloned, excluded } = cloneWithRedactions(single, this._getAttributesToFilter(context, single, kind, redactAllAttributes));
if (context.legacy) {
legacyTopLevelCopyAttributes.forEach((name) => {
if (name in cloned) {
cloned[name] = String(cloned[name]);
}
});
}
if (excluded.length) {
if (!cloned._meta) {
cloned._meta = {};
}
cloned._meta.redactedAttributes = excluded;
}
if (cloned._meta) {
delete cloned._meta.privateAttributes;
if (Object.keys(cloned._meta).length === 0) {
delete cloned._meta;
}
}
return cloned;
}
}
const MAX_RETRY_DELAY = 30 * 1000; // Maximum retry delay 30 seconds.
const JITTER_RATIO = 0.5; // Delay should be 50%-100% of calculated time.
/**
* Implements exponential backoff and jitter. This class tracks successful connections and failures
* and produces a retry delay.
*
* It does not start any timers or directly control a connection.
*
* The backoff follows an exponential backoff scheme with 50% jitter starting at
* initialRetryDelayMillis and capping at MAX_RETRY_DELAY. If RESET_INTERVAL has elapsed after a
* success, without an intervening faulure, then the backoff is reset to initialRetryDelayMillis.
*/
class DefaultBackoff {
constructor(initialRetryDelayMillis, _retryResetIntervalMillis, _random = Math.random) {
this._retryResetIntervalMillis = _retryResetIntervalMillis;
this._random = _random;
this._retryCount = 0;
// Initial retry delay cannot be 0.
this._initialRetryDelayMillis = Math.max(1, initialRetryDelayMillis);
this._maxExponent = Math.ceil(Math.log2(MAX_RETRY_DELAY / this._initialRetryDelayMillis));
}
_backoff() {
const exponent = Math.min(this._retryCount, this._maxExponent);
const delay = this._initialRetryDelayMillis * 2 ** exponent;
return Math.min(delay, MAX_RETRY_DELAY);
}
_jitter(computedDelayMillis) {
return computedDelayMillis - Math.trunc(this._random() * JITTER_RATIO * computedDelayMillis);
}
/**
* This function should be called when a connection attempt is successful.
*
* @param timeStampMs The time of the success. Used primarily for testing, when not provided
* the current time is used.
*/
success(timeStampMs = Date.now()) {
this._activeSince = timeStampMs;
}
/**
* This function should be called when a connection fails. It returns the a delay, in
* milliseconds, after which a reconnection attempt should be made.
*
* @param timeStampMs The time of the success. Used primarily for testing, when not provided
* the current time is used.
* @returns The delay before the next connection attempt.
*/
fail(timeStampMs = Date.now()) {
// If the last successful connection was active for more than the RESET_INTERVAL, then we
// return to the initial retry delay.
if (this._activeSince !== undefined &&
timeStampMs - this._activeSince > this._retryResetIntervalMillis) {
this._retryCount = 0;
}
this._activeSince = undefined;
const delay = this._jitter(this._backoff());
this._retryCount += 1;
return delay;
}
}
/**
* Handler that connects the current {@link DataSource} to the {@link CompositeDataSource}. A single
* {@link CallbackHandler} should only be given to one {@link DataSource}. Use {@link disable()} to
* prevent additional callback events.
*/
class CallbackHandler {
constructor(_dataCallback, _statusCallback) {
this._dataCallback = _dataCallback;
this._statusCallback = _statusCallback;
this._disabled = false;
}
disable() {
this._disabled = true;
}
async dataHandler(basis, data) {
if (this._disabled) {
return;
}
this._dataCallback(basis, data);
}
async statusHandler(status, err) {
if (this._disabled) {
return;
}
this._statusCallback(status, err);
}
}
// TODO: refactor client-sdk to use this enum
/**
* @experimental
* This feature is not stable and not subject to any backwards compatibility guarantees or semantic
* versioning. It is not suitable for production usage.
*/
var DataSourceState;
(function (DataSourceState) {
// Positive confirmation of connection/data receipt
DataSourceState[DataSourceState["Valid"] = 0] = "Valid";
// Spinning up to make first connection attempt
DataSourceState[DataSourceState["Initializing"] = 1] = "Initializing";
// Transient issue, automatic retry is expected
DataSourceState[DataSourceState["Interrupted"] = 2] = "Interrupted";
// Data source was closed and will not retry automatically.
DataSourceState[DataSourceState["Closed"] = 3] = "Closed";
})(DataSourceState || (DataSourceState = {}));
/**
* Helper class for {@link CompositeDataSource} to manage iterating on data sources and removing them on the fly.
*/
class DataSourceList {
/**
* @param circular whether to loop off the end of the list back to the start
* @param initialList of content
*/
constructor(circular, initialList) {
this._list = initialList ? [...initialList] : [];
this._circular = circular;
this._pos = 0;
}
/**
* Returns the current head and then iterates.
*/
next() {
if (this._list.length <= 0 || this._pos >= this._list.length) {
return undefined;
}
const result = this._list[this._pos];
if (this._circular) {
this._pos = (this._pos + 1) % this._list.length;
}
else {
this._pos += 1;
}
return result;
}
/**
* Replaces all elements with the provided list and resets the position of head to the start.
*
* @param input that will replace existing list
*/
replace(input) {
this._list = [...input];
this._pos = 0;
}
/**
* Removes the provided element from the list. If the removed element was the head, head moves to next. Consider head may be undefined if list is empty after removal.
*
* @param element to remove
* @returns true if element was removed
*/
remove(element) {
const index = this._list.indexOf(element);
if (index < 0) {
return false;
}
this._list.splice(index, 1);
if (this._list.length > 0) {
// if removed item was before head, adjust head
if (index < this._pos) {
this._pos -= 1;
}
if (this._circular && this._pos > this._list.length - 1) {
this._pos = 0;
}
}
return true;
}
/**
* Reset the head position to the start of the list.
*/
reset() {
this._pos = 0;
}
/**
* @returns the current head position in the list, 0 indexed.
*/
pos() {
return this._pos;
}
/**
* @returns the current length of the list
*/
length() {
return this._list.length;
}
/**
* Clears the list and resets head.
*/
clear() {
this._list = [];
this._pos = 0;
}
}
class LDFileDataSourceError extends Error {
constructor(message) {
super(message);
this.name = 'LaunchDarklyFileDataSourceError';
}
}
class LDPollingError extends Error {
constructor(kind, message, status, recoverable = true) {
super(message);
this.kind = kind;
this.status = status;
this.name = 'LaunchDarklyPollingError';
this.recoverable = recoverable;
}
}
class LDStreamingError extends Error {
constructor(kind, message, code, recoverable = true) {
super(message);
this.kind = kind;
this.code = code;
this.name = 'LaunchDarklyStreamingError';
this.recoverable = recoverable;
}
}
/**
* This is a short term error and will be removed once FDv2 adoption is sufficient.
*/
class LDFlagDeliveryFallbackError extends Error {
constructor(kind, message, code) {
super(message);
this.kind = kind;
this.code = code;
this.name = 'LDFlagDeliveryFallbackError';
this.recoverable = false;
}
}
const DEFAULT_FALLBACK_TIME_MS = 2 * 60 * 1000;
const DEFAULT_RECOVERY_TIME_MS = 5 * 60 * 1000;
/**
* The {@link CompositeDataSource} can combine a number of {@link DataSystemInitializer}s and {@link DataSystemSynchronizer}s
* into a single {@link DataSource}, implementing fallback and recovery logic internally to choose where data is sourced from.
*/
class CompositeDataSource {
/**
* @param initializers factories to create {@link DataSystemInitializer}s, in priority order.
* @param synchronizers factories to create {@link DataSystemSynchronizer}s, in priority order.
* @param fdv1Synchronizers factories to fallback to if we need to fallback to FDv1.
* @param _logger for logging
* @param _transitionConditions to control automated transition between datasources. Typically only used for testing.
* @param _backoff to control delay between transitions. Typically only used for testing.
*/
constructor(initializers, synchronizers, fdv1Synchronizers, _logger, _transitionConditions = {
[DataSourceState.Valid]: {
durationMS: DEFAULT_RECOVERY_TIME_MS,
transition: 'recover',
},
[DataSourceState.Interrupted]: {
durationMS: DEFAULT_FALLBACK_TIME_MS,
transition: 'fallback',
},
}, _backoff = new DefaultBackoff(1000, 30000)) {
this._logger = _logger;
this._transitionConditions = _transitionConditions;
this._backoff = _backoff;
this._stopped = true;
this._cancelTokens = [];
this._cancellableDelay = (delayMS) => {
let timeout;
const promise = new Promise((res, _) => {
timeout = setTimeout(res, delayMS);
});
return {
promise,
cancel() {
if (timeout) {
clearTimeout(timeout);
timeout = undefined;
}
},
};
};
this._externalTransitionPromise = new Promise((resolveTransition) => {
this._externalTransitionResolve = resolveTransition;
});
this._initPhaseActive = initializers.length > 0; // init phase if we have initializers
this._initFactories = new DataSourceList(false, initializers);
this._syncFactories = new DataSourceList(true, synchronizers);
this._fdv1Synchronizers = new DataSourceList(true, fdv1Synchronizers);
}
async start(dataCallback, statusCallback, selectorGetter) {
if (!this._stopped) {
// don't allow multiple simultaneous runs
this._logger?.info('CompositeDataSource already running. Ignoring call to start.');
return;
}
this._stopped = false;
this._logger?.debug(`CompositeDataSource starting with (${this._initFactories.length()} initializers, ${this._syncFactories.length()} synchronizers).`);
// this wrapper turns status updates from underlying data sources into a valid series of status updates for the consumer of this
// composite data source
const sanitizedStatusCallback = this._wrapStatusCallbackWithSanitizer(statusCallback);
sanitizedStatusCallback(DataSourceState.Initializing);
let lastTransition;
// eslint-disable-next-line no-constant-condition
while (true) {
const { dataSource: currentDS, isPrimary, cullDSFactory, } = this._pickDataSource(lastTransition);
const internalTransitionPromise = new Promise((transitionResolve) => {
if (currentDS) {
// these local variables are used for handling automatic transition related to data source status (ex: recovering to primary after
// secondary has been valid for N many seconds)
let lastState;
let cancelScheduledTransition = () => { };
// this callback handler can be disabled and ensures only one transition request occurs
const callbackHandler = new CallbackHandler((basis, data) => {
this._backoff.success();
dataCallback(basis, data);
if (basis && this._initPhaseActive) {
// transition to sync if we get basis during init
callbackHandler.disable();
this._consumeCancelToken(cancelScheduledTransition);
sanitizedStatusCallback(DataSourceState.Interrupted);
transitionResolve({ transition: 'switchToSync' });
}
}, (state, err) => {
// When we get a status update, we want to fallback if it is an error. We also want to schedule a transition for some
// time in the future if this status remains for some duration (ex: Recover to primary synchronizer after the secondary
// synchronizer has been Valid for some time). These scheduled transitions are configurable in the constructor.
this._logger?.debug(`CompositeDataSource received state ${state} from underlying data source. Err is ${err}`);
if (err || state === DataSourceState.Closed) {
callbackHandler.disable();
if (err?.recoverable === false) {
// don't use this datasource's factory again
this._logger?.debug(`Culling data source due to err ${err}`);
cullDSFactory?.();
// this error indicates we should fallback to only using FDv1 synchronizers
if (err instanceof LDFlagDeliveryFallbackError) {
this._logger?.debug(`Falling back to FDv1`);
this._syncFactories = this._fdv1Synchronizers;
}
}
sanitizedStatusCallback(state, err);
this._consumeCancelToken(cancelScheduledTransition);
transitionResolve({ transition: 'fallback', err }); // unrecoverable error has occurred, so fallback
}
else {
sanitizedStatusCallback(state);
if (state !== lastState) {
lastState = state;
this._consumeCancelToken(cancelScheduledTransition); // cancel previously scheduled status transition if one was scheduled
// primary source cannot recover to itself, so exclude it
const condition = this._lookupTransitionCondition(state, isPrimary);
if (condition) {
const { promise, cancel } = this._cancellableDelay(condition.durationMS);
cancelScheduledTransition = cancel;
this._cancelTokens.push(cancelScheduledTransition);
promise.then(() => {
this._consumeCancelToken(cancel);
callbackHandler.disable();
sanitizedStatusCallback(DataSourceState.Interrupted);
transitionResolve({ transition: condition.transition });
});
}
}
}
});
currentDS.start((basis, data) => callbackHandler.dataHandler(basis, data), (status, err) => callbackHandler.statusHandler(status, err), selectorGetter);
}
else {
// we don't have a data source to use!
transitionResolve({
transition: 'stop',
err: {
name: 'ExhaustedDataSources',
message: `CompositeDataSource has exhausted all configured initializers and synchronizers.`,
},
});
}
});
// await transition triggered by internal data source or an external stop request
let transitionRequest = await Promise.race([
internalTransitionPromise,
this._externalTransitionPromise,
]);
// stop the underlying datasource before transitioning to next state
currentDS?.stop();
if (transitionRequest.err && transitionRequest.transition !== 'stop') {
// if the transition was due to an error we're not in the initializer phase, throttle the transition. Fallback between initializers is not throttled.
const delay = this._initPhaseActive ? 0 : this._backoff.fail();
const { promise, cancel: cancelDelay } = this._cancellableDelay(delay);
this._cancelTokens.push(cancelDelay);
const delayedTransition = promise.then(() => {
this._consumeCancelToken(cancelDelay);
return transitionRequest;
});
// race the delayed transition and external transition requests to be responsive
transitionRequest = await Promise.race([
delayedTransition,
this._externalTransitionPromise,
]);
// consume the delay cancel token (even if it resolved, need to stop tracking its token)
this._consumeCancelToken(cancelDelay);
}
lastTransition = transitionRequest.transition;
if (transitionRequest.transition === 'stop') {
// exit the loop, this is intentionally not the sanitized status callback
statusCallback(DataSourceState.Closed, transitionRequest.err);
break;
}
}
// reset so that run can be called again in the future
this._reset();
}
async stop() {
this._cancelTokens.forEach((cancel) => cancel());
this._cancelTokens = [];
this._externalTransitionResolve?.({ transition: 'stop' });
}
_reset() {
this._stopped = true;
this._initPhaseActive = this._initFactories.length() > 0; // init phase if we have initializers;
this._initFactories.reset();
this._syncFactories.reset();
this._fdv1Synchronizers.reset();
this._externalTransitionPromise = new Promise((tr) => {
this._externalTransitionResolve = tr;
});
// intentionally not resetting the backoff to avoid a code path that could circumvent throttling
}
/**
* Determines the next datasource and returns that datasource as well as a closure to cull the
* datasource from the datasource lists. One example where the cull closure is invoked is if the
* datasource has an unrecoverable error.
*/
_pickDataSource(transition) {
let factory;
let isPrimary;
switch (transition) {
case 'switchToSync':
this._initPhaseActive = false; // one way toggle to false, unless this class is reset()
this._syncFactories.reset();
isPrimary = this._syncFactories.pos() === 0;
factory = this._syncFactories.next();
break;
case 'recover':
if (this._initPhaseActive) {
this._initFactories.reset();
isPrimary = this._initFactories.pos() === 0;
factor