UNPKG

@launchdarkly/js-sdk-common

Version:
1,317 lines (1,304 loc) 124 kB
'use strict'; /** * 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;