UNPKG

flagsmith-nodejs

Version:

Flagsmith lets you manage features flags and remote config across web, mobile and server side applications. Deliver true Continuous Integration. Get builds out faster. Control who has access to new features.

438 lines (437 loc) 20 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.Flagsmith = exports.EnvironmentDataPollingManager = exports.Flags = exports.DefaultFlag = exports.BaseFlag = exports.FlagsmithClientError = exports.FlagsmithAPIError = exports.AnalyticsProcessor = void 0; const util_js_1 = require("../flagsmith-engine/environments/util.js"); const analytics_js_1 = require("./analytics.js"); const errors_js_1 = require("./errors.js"); const models_js_1 = require("./models.js"); const polling_manager_js_1 = require("./polling_manager.js"); const utils_js_1 = require("./utils.js"); const index_js_1 = require("../flagsmith-engine/index.js"); const pino_1 = require("pino"); const mappers_js_1 = require("../flagsmith-engine/evaluation/evaluationContext/mappers.js"); var analytics_js_2 = require("./analytics.js"); Object.defineProperty(exports, "AnalyticsProcessor", { enumerable: true, get: function () { return analytics_js_2.AnalyticsProcessor; } }); var errors_js_2 = require("./errors.js"); Object.defineProperty(exports, "FlagsmithAPIError", { enumerable: true, get: function () { return errors_js_2.FlagsmithAPIError; } }); Object.defineProperty(exports, "FlagsmithClientError", { enumerable: true, get: function () { return errors_js_2.FlagsmithClientError; } }); var models_js_2 = require("./models.js"); Object.defineProperty(exports, "BaseFlag", { enumerable: true, get: function () { return models_js_2.BaseFlag; } }); Object.defineProperty(exports, "DefaultFlag", { enumerable: true, get: function () { return models_js_2.DefaultFlag; } }); Object.defineProperty(exports, "Flags", { enumerable: true, get: function () { return models_js_2.Flags; } }); var polling_manager_js_2 = require("./polling_manager.js"); Object.defineProperty(exports, "EnvironmentDataPollingManager", { enumerable: true, get: function () { return polling_manager_js_2.EnvironmentDataPollingManager; } }); const DEFAULT_API_URL = 'https://edge.api.flagsmith.com/api/v1/'; const DEFAULT_REQUEST_TIMEOUT_SECONDS = 10; /** * A client for evaluating Flagsmith feature flags. * * Flags are evaluated remotely by the Flagsmith API over HTTP by default. * To evaluate flags locally, create the client using {@link FlagsmithConfig.enableLocalEvaluation} and a server-side SDK key. * * @example * import { Flagsmith, Flags, DefaultFlag } from 'flagsmith-nodejs' * * const flagsmith = new Flagsmith({ * environmentKey: 'your_sdk_key', * defaultFlagHandler: (flagKey: string) => { new DefaultFlag(...) }, * }); * * // Fetch the current environment flags * const environmentFlags: Flags = flagsmith.getEnvironmentFlags() * const isFooEnabled: boolean = environmentFlags.isFeatureEnabled('foo') * * // Evaluate flags for any identity * const identityFlags: Flags = flagsmith.getIdentityFlags('my_user_123', {'vip': true}) * const bannerVariation: string = identityFlags.getFeatureValue('banner_flag') * * @see FlagsmithConfig */ class Flagsmith { environmentKey = undefined; apiUrl = undefined; analyticsUrl = undefined; customHeaders; agent; requestTimeoutMs; enableLocalEvaluation = false; environmentRefreshIntervalSeconds = 60; retries; enableAnalytics = false; defaultFlagHandler; environmentFlagsUrl; identitiesUrl; environmentUrl; environmentDataPollingManager; environment; offlineMode = false; offlineHandler = undefined; identitiesWithOverridesByIdentifier; cache; onEnvironmentChange; analyticsProcessor; logger; customFetch; requestRetryDelayMilliseconds; /** * Creates a new {@link Flagsmith} client. * * If using local evaluation, the environment will be fetched lazily when needed by any method. Polling the * environment for updates will start after {@link environmentRefreshIntervalSeconds} once the client is created. * @param data The {@link FlagsmithConfig} options for this client. */ constructor(data) { this.agent = data.agent; this.customFetch = data.fetch ?? fetch; this.environmentKey = data.environmentKey; this.apiUrl = data.apiUrl || DEFAULT_API_URL; this.customHeaders = data.customHeaders; this.requestTimeoutMs = 1000 * (data.requestTimeoutSeconds ?? DEFAULT_REQUEST_TIMEOUT_SECONDS); this.requestRetryDelayMilliseconds = data.requestRetryDelayMilliseconds ?? 1000; this.enableLocalEvaluation = data.enableLocalEvaluation; this.environmentRefreshIntervalSeconds = data.environmentRefreshIntervalSeconds || this.environmentRefreshIntervalSeconds; this.retries = data.retries; this.enableAnalytics = data.enableAnalytics || false; this.defaultFlagHandler = data.defaultFlagHandler; this.onEnvironmentChange = (error, result) => data.onEnvironmentChange?.(error, result); this.logger = data.logger || (0, pino_1.pino)(); this.offlineMode = data.offlineMode || false; this.offlineHandler = data.offlineHandler; // argument validation if (this.offlineMode && !this.offlineHandler) { throw new Error('ValueError: offlineHandler must be provided to use offline mode.'); } else if (this.defaultFlagHandler && this.offlineHandler) { throw new Error('ValueError: Cannot use both defaultFlagHandler and offlineHandler.'); } if (!!data.cache) { this.cache = data.cache; } if (!this.offlineMode) { if (!this.environmentKey) { throw new Error('ValueError: environmentKey is required.'); } const apiUrl = data.apiUrl || DEFAULT_API_URL; this.apiUrl = apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`; this.analyticsUrl = this.analyticsUrl || new URL(analytics_js_1.ANALYTICS_ENDPOINT, new Request(this.apiUrl).url).href; this.environmentFlagsUrl = `${this.apiUrl}flags/`; this.identitiesUrl = `${this.apiUrl}identities/`; this.environmentUrl = `${this.apiUrl}environment-document/`; if (this.enableLocalEvaluation) { if (!this.environmentKey.startsWith('ser.')) { throw new Error('Using local evaluation requires a server-side environment key'); } if (this.environmentRefreshIntervalSeconds > 0) { this.environmentDataPollingManager = new polling_manager_js_1.EnvironmentDataPollingManager(this, this.environmentRefreshIntervalSeconds, this.logger); this.environmentDataPollingManager.start(); } } if (data.enableAnalytics) { this.analyticsProcessor = new analytics_js_1.AnalyticsProcessor({ environmentKey: this.environmentKey, analyticsUrl: this.analyticsUrl, requestTimeoutMs: this.requestTimeoutMs, logger: this.logger }); } } } /** * Get all the default for flags for the current environment. * * @returns Flags object holding all the flags for the current environment. */ async getEnvironmentFlags() { const cachedItem = !!this.cache && (await this.cache.get(`flags`)); if (!!cachedItem) { return cachedItem; } try { if (this.enableLocalEvaluation || this.offlineMode) { return await this.getEnvironmentFlagsFromDocument(); } return await this.getEnvironmentFlagsFromApi(); } catch (error) { if (!this.defaultFlagHandler) { throw new Error('getEnvironmentFlags failed and no default flag handler was provided', { cause: error }); } this.logger.error(error, 'getEnvironmentFlags failed'); return new models_js_1.Flags({ flags: {}, defaultFlagHandler: this.defaultFlagHandler }); } } /** * Get all the flags for the current environment for a given identity. Will also upsert all traits to the Flagsmith API for future evaluations. Providing a trait with a value of None will remove the trait from the identity if it exists. * * @param {string} identifier a unique identifier for the identity in the current environment, e.g. email address, username, uuid * @param {{[key:string]:any | TraitConfig}} traits? a dictionary of traits to add / update on the identity in Flagsmith, e.g. {"num_orders": 10} or {age: {value: 30, transient: true}} * @returns Flags object holding all the flags for the given identity. */ async getIdentityFlags(identifier, traits, transient = false) { if (!identifier) { throw new Error('`identifier` argument is missing or invalid.'); } const cachedItem = !!this.cache && (await this.cache.get(`flags-${identifier}`)); if (!!cachedItem) { return cachedItem; } traits = traits || {}; try { if (this.enableLocalEvaluation || this.offlineMode) { return await this.getIdentityFlagsFromDocument(identifier, traits || {}); } return await this.getIdentityFlagsFromApi(identifier, traits, transient); } catch (error) { if (!this.defaultFlagHandler) { throw new Error('getIdentityFlags failed and no default flag handler was provided', { cause: error }); } this.logger.error(error, 'getIdentityFlags failed'); return new models_js_1.Flags({ flags: {}, defaultFlagHandler: this.defaultFlagHandler }); } } /** * Get the segments for the current environment for a given identity. Will also upsert all traits to the Flagsmith API for future evaluations. Providing a trait with a value of None will remove the trait from the identity if it exists. * * @param {string} identifier a unique identifier for the identity in the current environment, e.g. email address, username, uuid * @param {{[key:string]:any}} traits? a dictionary of traits to add / update on the identity in Flagsmith, e.g. {"num_orders": 10} * @returns Segments that the given identity belongs to. */ async getIdentitySegments(identifier, traits) { if (!identifier) { throw new Error('`identifier` argument is missing or invalid.'); } if (!this.enableLocalEvaluation) { this.logger.error('This function is only permitted with local evaluation.'); return Promise.resolve([]); } traits = traits || {}; const environment = await this.getEnvironment(); const identityModel = this.getIdentityModel(environment, identifier, Object.keys(traits || {}).map(key => ({ key, value: traits?.[key] }))); const context = (0, mappers_js_1.getEvaluationContext)(environment, identityModel); if (!context) { throw new errors_js_1.FlagsmithClientError('Local evaluation required to obtain identity segments'); } const evaluationResult = (0, index_js_1.getEvaluationResult)(context); return index_js_1.SegmentModel.fromSegmentResult(evaluationResult.segments, context); } async fetchEnvironment() { const deferred = new utils_js_1.Deferred(); this.environmentPromise = deferred.promise; try { const environment = await this.getEnvironmentFromApi(); this.environment = environment; if (environment.identityOverrides?.length) { this.identitiesWithOverridesByIdentifier = new Map(environment.identityOverrides.map(identity => [identity.identifier, identity])); } deferred.resolve(environment); return deferred.promise; } catch (error) { deferred.reject(error); return deferred.promise; } finally { this.environmentPromise = undefined; } } /** * Fetch the latest environment state from the Flagsmith API to use for local flag evaluation. * * If the environment is currently being fetched, calling this method will not cause additional fetches. */ async updateEnvironment() { try { if (this.environmentPromise) { await this.environmentPromise; return; } const environment = await this.fetchEnvironment(); this.onEnvironmentChange(null, environment); } catch (e) { this.logger.error(e, 'updateEnvironment failed'); this.onEnvironmentChange(e); } } async close() { this.environmentDataPollingManager?.stop(); } async getJSONResponse(url, method, body) { const headers = { 'Content-Type': 'application/json' }; if (this.environmentKey) { headers['X-Environment-Key'] = this.environmentKey; } headers['User-Agent'] = (0, utils_js_1.getUserAgent)(); if (this.customHeaders) { for (const [k, v] of Object.entries(this.customHeaders)) { headers[k] = v; } } const data = await (0, utils_js_1.retryFetch)(url, { dispatcher: this.agent, method: method, body: JSON.stringify(body), headers: headers }, this.retries, this.requestTimeoutMs, this.requestRetryDelayMilliseconds, this.customFetch); if (data.status !== 200) { throw new errors_js_1.FlagsmithAPIError(`Invalid request made to Flagsmith API. Response status code: ${data.status}`); } return { response: data, data: await data.json() }; } /** * This promise ensures that the environment is retrieved before attempting to locally evaluate. */ environmentPromise; /** * Returns the current environment, fetching it from the API if needed. * * Calling this method concurrently while the environment is being fetched will not cause additional requests. */ async getEnvironment() { if (this.offlineHandler) { return this.offlineHandler.getEnvironment(); } if (this.environment) { return this.environment; } if (!this.environmentPromise) { this.environmentPromise = this.fetchEnvironment(); } return this.environmentPromise; } async getEnvironmentFromApi() { if (!this.environmentUrl) { throw new Error('`apiUrl` argument is missing or invalid.'); } const startTime = Date.now(); const documents = []; let url = this.environmentUrl; let loggedWarning = false; while (true) { try { if (!loggedWarning) { const elapsedMs = Date.now() - startTime; if (elapsedMs > this.environmentRefreshIntervalSeconds * 1000) { this.logger.warn(`Environment document retrieval exceeded the polling interval of ${this.environmentRefreshIntervalSeconds} seconds.`); loggedWarning = true; } } const { response, data } = await this.getJSONResponse(url, 'GET'); documents.push(data); const linkHeader = response.headers.get('link'); if (linkHeader) { const nextMatch = linkHeader.match(/<([^>]+)>;\s*rel="next"/); if (nextMatch) { const relativeUrl = decodeURIComponent(nextMatch[1]); url = new URL(relativeUrl, this.apiUrl).href; continue; } } break; } catch (error) { throw error; } } // Compile the document const compiledDocument = documents[0]; for (let i = 1; i < documents.length; i++) { compiledDocument.identity_overrides = compiledDocument.identity_overrides || []; compiledDocument.identity_overrides.push(...(documents[i].identity_overrides || [])); } return (0, util_js_1.buildEnvironmentModel)(compiledDocument); } async getEnvironmentFlagsFromDocument() { const environment = await this.getEnvironment(); const context = (0, mappers_js_1.getEvaluationContext)(environment, undefined, undefined, true); if (!context) { throw new errors_js_1.FlagsmithClientError('Unable to get flags. No environment present.'); } const evaluationResult = (0, index_js_1.getEvaluationResult)(context); const flags = models_js_1.Flags.fromEvaluationResult(evaluationResult); if (!!this.cache) { await this.cache.set('flags', flags); } return flags; } async getIdentityFlagsFromDocument(identifier, traits) { const environment = await this.getEnvironment(); const identityModel = this.getIdentityModel(environment, identifier, Object.keys(traits).map(key => ({ key, value: traits[key] }))); const context = (0, mappers_js_1.getEvaluationContext)(environment, identityModel); if (!context) { throw new errors_js_1.FlagsmithClientError('Unable to get flags. No environment present.'); } const evaluationResult = (0, index_js_1.getEvaluationResult)(context); const flags = models_js_1.Flags.fromEvaluationResult(evaluationResult, this.defaultFlagHandler, this.analyticsProcessor); if (!!this.cache) { await this.cache.set(`flags-${identifier}`, flags); } return flags; } async getEnvironmentFlagsFromApi() { if (!this.environmentFlagsUrl) { throw new Error('`apiUrl` argument is missing or invalid.'); } const { data: apiFlags } = await this.getJSONResponse(this.environmentFlagsUrl, 'GET'); const flags = models_js_1.Flags.fromAPIFlags({ apiFlags: apiFlags, analyticsProcessor: this.analyticsProcessor, defaultFlagHandler: this.defaultFlagHandler }); if (!!this.cache) { await this.cache.set('flags', flags); } return flags; } async getIdentityFlagsFromApi(identifier, traits, transient = false) { if (!this.identitiesUrl) { throw new Error('`apiUrl` argument is missing or invalid.'); } const data = (0, utils_js_1.generateIdentitiesData)(identifier, traits, transient); const { data: jsonResponse } = await this.getJSONResponse(this.identitiesUrl, 'POST', data); const flags = models_js_1.Flags.fromAPIFlags({ apiFlags: jsonResponse['flags'], analyticsProcessor: this.analyticsProcessor, defaultFlagHandler: this.defaultFlagHandler }); if (!!this.cache) { await this.cache.set(`flags-${identifier}`, flags); } return flags; } getIdentityModel(environment, identifier, traits) { const traitModels = traits.map(trait => new index_js_1.TraitModel(trait.key, trait.value)); let identityWithOverrides = this.identitiesWithOverridesByIdentifier?.get(identifier); if (identityWithOverrides) { identityWithOverrides.updateTraits(traitModels); return identityWithOverrides; } return new index_js_1.IdentityModel('0', traitModels, [], environment.apiKey, identifier); } } exports.Flagsmith = Flagsmith; exports.default = Flagsmith;