UNPKG

@launchdarkly/js-sdk-common

Version:
1,424 lines (1,406 loc) 90.3 kB
/** * 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))); } } /** * 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; } // 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; } } 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; } } var DataSourceErrorKind; (function (DataSourceErrorKind) { /// An unexpected error, such as an uncaught exception, further /// described by the error message. DataSourceErrorKind["Unknown"] = "UNKNOWN"; /// An I/O error such as a dropped connection. DataSourceErrorKind["NetworkError"] = "NETWORK_ERROR"; /// The LaunchDarkly service returned an HTTP response with an error /// status, available in the status code. DataSourceErrorKind["ErrorResponse"] = "ERROR_RESPONSE"; /// The SDK received malformed data from the LaunchDarkly service. DataSourceErrorKind["InvalidData"] = "INVALID_DATA"; })(DataSourceErrorKind || (DataSourceErrorKind = {})); 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; } } /* eslint-disable import/prefer-default-export */ /** * Enable / disable Auto environment attributes. When enabled, the SDK will automatically * provide data about the mobile environment where the application is running. This data makes it simpler to target * your mobile customers based on application name or version, or on device characteristics including manufacturer, * model, operating system, locale, and so on. We recommend enabling this when you configure the SDK. To learn more, * read [Automatic environment attributes](https://docs.launchdarkly.com/sdk/features/environment-attributes). * for more documentation. */ var AutoEnvAttributes; (function (AutoEnvAttributes) { AutoEnvAttributes[AutoEnvAttributes["Disabled"] = 0] = "Disabled"; AutoEnvAttributes[AutoEnvAttributes["Enabled"] = 1] = "Enabled"; })(AutoEnvAttributes || (AutoEnvAttributes = {})); var LDEventType; (function (LDEventType) { LDEventType[LDEventType["AnalyticsEvents"] = 0] = "AnalyticsEvents"; LDEventType[LDEventType["DiagnosticEvent"] = 1] = "DiagnosticEvent"; })(LDEventType || (LDEventType = {})); var LDDeliveryStatus; (function (LDDeliveryStatus) { LDDeliveryStatus[LDDeliveryStatus["Succeeded"] = 0] = "Succeeded"; LDDeliveryStatus[LDDeliveryStatus["Failed"] = 1] = "Failed"; LDDeliveryStatus[LDDeliveryStatus["FailedAndMustShutDown"] = 2] = "FailedAndMustShutDown"; })(LDDeliveryStatus || (LDDeliveryStatus = {})); var index$1 = /*#__PURE__*/Object.freeze({ __proto__: null, get LDDeliveryStatus () { return LDDeliveryStatus; }, get LDEventType () { return LDEventType; } }); /** * Attempt to produce a string representation of a value. * The format should be roughly comparable to `util.format` * aside from object which will be JSON versus the `util.inspect` * format. * @param val * @returns A string representation of the value if possible. */ function tryStringify(val) { if (typeof val === 'string') { return val; } if (val === undefined) { return 'undefined'; } if (val === null) { return 'null'; } if (Object.prototype.hasOwnProperty.call(val, 'toString')) { try { return val.toString(); } catch { /* Keep going */ } } if (typeof val === 'bigint') { return `${val}n`; } try { return JSON.stringify(val); } catch (error) { if (error instanceof TypeError && error.message.indexOf('circular') >= 0) { return '[Circular]'; } return '[Not Stringifiable]'; } } /** * Attempt to produce a numeric representation. * BigInts have an `n` suffix. * @param val * @returns The numeric representation or 'NaN' if not numeric. */ function toNumber(val) { // Symbol has to be treated special because it will // throw an exception if an attempt is made to convert it. if (typeof val === 'symbol') { return 'NaN'; } if (typeof val === 'bigint') { return `${val}n`; } return String(Number(val)); } /** * Attempt to produce an integer representation. * BigInts have an `n` suffix. * @param val * @returns The integer representation or 'NaN' if not numeric. */ function toInt(val) { if (typeof val === 'symbol') { return 'NaN'; } if (typeof val === 'bigint') { return `${val}n`; } return String(parseInt(val, 10)); } /** * Attempt to produce a float representation. * BigInts have an `n` suffix. * @param val * @returns The integer representation or 'NaN' if not numeric. */ function toFloat(val) { if (typeof val === 'symbol') { return 'NaN'; } return String(parseFloat(val)); } // Based on: // https://nodejs.org/api/util.html#utilformatformat-args // The result will not match node exactly, but it should get the // right information through. const escapes = { s: (val) => tryStringify(val), d: (val) => toNumber(val), i: (val) => toInt(val), f: (val) => toFloat(val), j: (val) => tryStringify(val), o: (val) => tryStringify(val), // eslint-disable-next-line @typescript-eslint/naming-convention O: (val) => tryStringify(val), c: () => '', }; /** * A basic formatted for use where `util.format` is not available. * This will not be as performant, but it will produce formatted * messages. * * @internal * * @param args * @returns Formatted string. */ function format(...args) { const formatString = args.shift(); if (TypeValidators.String.is(formatString)) { let out = ''; let i = 0; while (i < formatString.length) { const char = formatString.charAt(i); if (char === '%') { const nextIndex = i + 1; if (nextIndex < formatString.length) { const nextChar = formatString.charAt(i + 1); if (nextChar in escapes && args.length) { const value = args.shift(); // This rule is for math. // eslint-disable-next-line no-unsafe-optional-chaining out += escapes[nextChar]?.(value); } else if (nextChar === '%') { out += '%'; } else { out += `%${nextChar}`; } i += 2; } } else { out += char; i += 1; } } // If there are any args left after we exhaust the format string // then just stick those on the end. if (args.length) { if (out.length) { out += ' '; } out += args.map(tryStringify).join(' '); } return out; } return args.map(tryStringify).join(' '); } var LogPriority; (function (LogPriority) { LogPriority[LogPriority["debug"] = 0] = "debug"; LogPriority[LogPriority["info"] = 1] = "info"; LogPriority[LogPriority["warn"] = 2] = "warn"; LogPriority[LogPriority["error"] = 3] = "error"; LogPriority[LogPriority["none"] = 4] = "none"; })(LogPriority || (LogPriority = {})); const LEVEL_NAMES = ['debug', 'info', 'warn', 'error', 'none']; /** * A basic logger which handles filtering by level. * * With the default options it will write to `console.error` * and it will use the formatting provided by `console.error`. * If the destination is overwritten, then it will use an included * formatter similar to `util.format`. * * If a formatter is available, then that should be overridden * as well for performance. */ class BasicLogger { /** * This should only be used as a default fallback and not as a convenient * solution. In most cases you should construct a new instance with the * appropriate options for your specific needs. */ static get() { return new BasicLogger({}); } constructor(options) { this._logLevel = LogPriority[options.level ?? 'info'] ?? LogPriority.info; this._name = options.name ?? 'LaunchDarkly'; this._formatter = options.formatter; if (typeof options.destination === 'object') { this._destinations = { [LogPriority.debug]: options.destination.debug, [LogPriority.info]: options.destination.info, [LogPriority.warn]: options.destination.warn, [LogPriority.error]: options.destination.error, }; } else if (typeof options.destination === 'function') { const { destination } = options; this._destinations = { [LogPriority.debug]: destination, [LogPriority.info]: destination, [LogPriority.warn]: destination, [LogPriority.error]: destination, }; } } _tryFormat(...args) { try { if (this._formatter) { // In case the provided formatter fails. return this._formatter?.(...args); } return format(...args); } catch { return format(...args); } } _tryWrite(destination, msg) { try { destination(msg); } catch { // eslint-disable-next-line no-console console.error(msg); } } _log(level, args) { if (level >= this._logLevel) { const prefix = `${LEVEL_NAMES[level]}: [${this._name}]`; try { const destination = this._destinations?.[level]; if (destination) { this._tryWrite(destination, `${prefix} ${this._tryFormat(...args)}`); } else { // `console.error` has its own formatter. // So we don't need to do anything. // eslint-disable-next-line no-console console.error(...args); } } catch { // If all else fails do not break. // eslint-disable-next-line no-console console.error(...args); } } } error(...args) { this._log(LogPriority.error, args); } warn(...args) { this._log(LogPriority.warn, args); } info(...args) { this._log(LogPriority.info, args); } debug(...args) { this._log(LogPriority.debug, args); } } const loggerRequirements = { error: TypeValidators.Function, warn: TypeValidators.Function, info: TypeValidators.Function, debug: TypeValidators.Function, }; /** * The safeLogger logic exists because we allow the application to pass in a custom logger, but * there is no guarantee that the logger works correctly and if it ever throws exceptions there * could be serious consequences (e.g. an uncaught exception within an error event handler, due * to the SDK trying to log the error, can terminate the application). An exception could result * from faulty logic in the logger implementation, or it could be that this is not a logger at * all but some other kind of object; the former is handled by a catch block that logs an error * message to the SDK's default logger, and we can at least partly guard against the latter by * checking for the presence of required methods at configuration time. */ class SafeLogger { /** * Construct a safe logger with the specified logger. * @param logger The logger to use. * @param fallback A fallback logger to use in case an issue is encountered using * the provided logger. */ constructor(logger, fallback) { Object.entries(loggerRequirements).forEach(([level, validator]) => { if (!validator.is(logger[level])) { throw new Error(`Provided logger instance must support logger.${level}(...) method`); // Note that the SDK normally does not throw exceptions to the application, but that rule // does not apply to LDClient.init() which will throw an exception if the parameters are so // invalid that we cannot proceed with creating the client. An invalid logger meets those // criteria since the SDK calls the logger during nearly all of its operations. } }); this._logger = logger; this._fallback = fallback; } _log(level, args) { try { this._logger[level](...args); } catch { // If all else fails do not break. this._fallback[level](...args); } } error(...args) { this._log('error', args); } warn(...args) { this._log('warn', args); } info(...args) { this._log('info', args); } debug(...args) { this._log('debug', args); } } const createSafeLogger = (logger) => { const basicLogger = new BasicLogger({ level: 'info', // eslint-disable-next-line no-console destination: console.error, formatter: format, }); return logger ? new SafeLogger(logger, basicLogger) : basicLogger; }; /** * Messages for issues which can be encountered from processing the configuration options. */ class OptionMessages { static deprecated(oldName, newName) { return `"${oldName}" is deprecated, please use "${newName}"`; } static optionBelowMinimum(name, value, min) { return `Config option "${name}" had invalid value of ${value}, using minimum of ${min} instead`; } static unknownOption(name) { return `Ignoring unknown config option "${name}"`; } static wrongOptionType(name, expectedType, actualType) { return `Config option "${name}" should be of type ${expectedType}, got ${actualType}, using default value`; } static wrongOptionTypeBoolean(name, actualType) { return `Config option "${name}" should be a boolean, got ${actualType}, converting to boolean`; } static invalidTagValue(name) { return `Config option "${name}" must only contain letters, numbers, ., _ or -.`; } static tagValueTooLong(name) { return `Value of "${name}" was longer than 64 characters and was discarded.`; } static partialEndpoint(name) { return `You have set custom uris without specifying the ${name} URI; connections may not work properly`; } } /** * Expression to validate characters that are allowed in tag keys and values. */ const allowedTagCharacters = /^(\w|\.|-)+$/; const regexValidator = TypeValidators.stringMatchingRegex(allowedTagCharacters); const tagValidator = { is: (u, name) => { if (regexValidator.is(u)) { if (u.length > 64) { return { valid: false, message: OptionMessages.tagValueTooLong(name) }; } return { valid: true }; } return { valid: false, message: OptionMessages.invalidTagValue(name) }; }, }; /** * Class for managing tags. */ class ApplicationTags { constructor(options) { const tags = {}; const application = options?.application; const logger = options?.logger; if (application) { Object.entries(application).forEach(([key, value]) => { if (value !== null && value !== undefined) { const { valid, message } = tagValidator.is(value, `application.${key}`); if (!valid) { logger?.warn(message); } else if (key === 'versionName') { tags[`application-version-name`] = [value]; } else { tags[`application-${key}`] = [value]; } } }); } const tagKeys = Object.keys(tags); if (tagKeys.length) { this.value = tagKeys .sort() .flatMap((key) => tags[key].sort().map((value) => `${key}/${value}`)) .join(' '); } } } /** * The client context provides basic configuration and platform support which are required * when building SDK components. */ class ClientContext { constructor(sdkKey, configuration, platform) { this.platform = platform; this.basicConfiguration = { tags: configuration.tags, logger: configuration.logger, offline: configuration.offline, serviceEndpoints: configuration.serviceEndpoints, sdkKey, }; } } function canonicalizeUri(uri) { return uri.replace(/\/+$/, ''); } function canonicalizePath(path) { return path.replace(/^\/+/, '').replace(/\?$/, ''); } /** * Specifies the base service URIs used by SDK components. */ class ServiceEndpoints { constructor(streaming, polling, events = ServiceEndpoints.DEFAULT_EVENTS, analyticsEventPath = '/bulk', diagnosticEventPath = '/diagnostic', includeAuthorizationHeader = true, payloadFilterKey) { this.streaming = canonicalizeUri(streaming); this.polling = canonicalizeUri(polling); this.events = canonicalizeUri(events); this.analyticsEventPath = analyticsEventPath; this.diagnosticEventPath = diagnosticEventPath; this.includeAuthorizationHeader = includeAuthorizationHeader; this.payloadFilterKey = payloadFilterKey; } } // eslint-disable-next-line @typescript-eslint/naming-convention ServiceEndpoints.DEFAULT_EVENTS = 'https://events.launchdarkly.com'; function getWithParams(uri, parameters) { if (parameters.length === 0) { return uri; } const parts = parameters.map(({ key, value }) => `${key}=${value}`); return `${uri}?${parts.join('&')}`; } /** * Get the URI for the streaming endpoint. * * @param endpoints The service endpoints. * @param path The path to the resource, devoid of any query parameters or hrefs. * @param parameters The query parameters. These query parameters must already have the appropriate encoding applied. This function WILL NOT apply it for you. */ function getStreamingUri(endpoints, path, parameters) { const canonicalizedPath = canonicalizePath(path); const combinedParameters = [...parameters]; if (endpoints.payloadFilterKey) { combinedParameters.push({ key: 'filter', value: endpoints.payloadFilterKey }); } return getWithParams(`${endpoints.streaming}/${canonicalizedPath}`, combinedParameters); } /** * Get the URI for the polling endpoint. * * @param endpoints The service endpoints. * @param path The path to the resource, devoid of any query parameters or hrefs. * @param parameters The query parameters. These query parameters must already have the appropriate encoding applied. This function WILL NOT apply it for you. */ function getPollingUri(endpoints, path, parameters) { const canonicalizedPath = canonicalizePath(path); const combinedParameters = [...parameters]; if (endpoints.payloadFilterKey) { combinedParameters.push({ key: 'filter', value: endpoints.payloadFilterKey }); } return getWithParams(`${endpoints.polling}/${canonicalizedPath}`, combinedParameters); } /** * Get the URI for the events endpoint. * * @param endpoints The service endpoints. * @param path The path to the resource, devoid of any query parameters or hrefs. * @param parameters The query parameters. These query parameters must already have the appropriate encoding applied. This function WILL NOT apply it for you. */ function getEventsUri(endpoints, path, parameters) { const canonicalizedPath = canonicalizePath(path); return getWithParams(`${endpoints.events}/${canonicalizedPath}`, parameters); } // These classes are of trivial complexity. If they become // more complex, then they could be independent files. /* eslint-disable max-classes-per-file */ class LDUnexpectedResponseError extends Error { constructor(message) { super(message); this.name = 'LaunchDarklyUnexpectedResponseError'; } } class LDClientError extends Error { constructor(message) { super(message); this.name = 'LaunchDarklyClientError'; } } class LDTimeoutError extends Error { constructor(message) { super(message); this.name = 'LaunchDarklyTimeoutError'; } } /** * Check if the HTTP error is recoverable. This will return false if a request * made with any payload could not recover. If the reason for the failure * is payload specific, for instance a payload that is too large, then * it could recover with a different payload. */ function isHttpRecoverable(status) { if (status >= 400 && status < 500) { return status === 400 || status === 408 || status === 429; } return true; } /** * Returns true if the status could recover for a different payload. * * When used with event processing this indicates that we should discard * the payload, but that a subsequent payload may succeed. Therefore we should * not stop event processing. */ function isHttpLocallyRecoverable(status) { if (status