UNPKG

@featurevisor/sdk

Version:

Featurevisor SDK for Node.js and the browser

589 lines (480 loc) 15.7 kB
import { Context, DatafileContent, Feature, FeatureKey, InitialFeatures, StickyFeatures, VariableType, VariableValue, VariationValue, VariableKey, } from "@featurevisor/types"; import { createLogger, Logger, LogLevel } from "./logger"; import { DatafileReader } from "./datafileReader"; import { Emitter } from "./emitter"; import { ConfigureBucketKey, ConfigureBucketValue } from "./bucket"; import { Evaluation, evaluate } from "./evaluate"; export type ReadyCallback = () => void; export type ActivationCallback = ( featureName: string, variation: VariationValue, context: Context, captureContext: Context, ) => void; export interface Statuses { ready: boolean; refreshInProgress: boolean; } const DEFAULT_BUCKET_KEY_SEPARATOR = "."; export type InterceptContext = (context: Context) => Context; export interface InstanceOptions { bucketKeySeparator?: string; configureBucketKey?: ConfigureBucketKey; configureBucketValue?: ConfigureBucketValue; datafile?: DatafileContent | string; datafileUrl?: string; handleDatafileFetch?: (datafileUrl: string) => Promise<DatafileContent>; initialFeatures?: InitialFeatures; interceptContext?: InterceptContext; logger?: Logger; onActivation?: ActivationCallback; onReady?: ReadyCallback; onRefresh?: () => void; onUpdate?: () => void; refreshInterval?: number; // seconds stickyFeatures?: StickyFeatures; } const emptyDatafile: DatafileContent = { schemaVersion: "1", revision: "unknown", attributes: [], segments: [], features: [], }; export type DatafileFetchHandler = (datafileUrl: string) => Promise<DatafileContent>; function fetchDatafileContent( datafileUrl, handleDatafileFetch?: DatafileFetchHandler, ): Promise<DatafileContent> { if (handleDatafileFetch) { return handleDatafileFetch(datafileUrl); } return fetch(datafileUrl).then((res) => res.json()); } type FieldType = string | VariableType; type ValueType = VariableValue; export function getValueByType(value: ValueType, fieldType: FieldType): ValueType { try { if (value === undefined) { return undefined; } switch (fieldType) { case "string": return typeof value === "string" ? value : undefined; case "integer": return parseInt(value as string, 10); case "double": return parseFloat(value as string); case "boolean": return value === true; case "array": return Array.isArray(value) ? value : undefined; case "object": return typeof value === "object" ? value : undefined; // @NOTE: `json` is not handled here intentionally default: return value; } } catch (e) { return undefined; } } export class FeaturevisorInstance { // from options private bucketKeySeparator: string; private configureBucketKey?: ConfigureBucketKey; private configureBucketValue?: ConfigureBucketValue; private datafileUrl?: string; private handleDatafileFetch?: DatafileFetchHandler; private initialFeatures?: InitialFeatures; private interceptContext?: InterceptContext; private logger: Logger; private refreshInterval?: number; // seconds private stickyFeatures?: StickyFeatures; // internally created private datafileReader: DatafileReader; private emitter: Emitter; private statuses: Statuses; private intervalId?: ReturnType<typeof setInterval>; // exposed from emitter public on: Emitter["addListener"]; public addListener: Emitter["addListener"]; public off: Emitter["removeListener"]; public removeListener: Emitter["removeListener"]; public removeAllListeners: Emitter["removeAllListeners"]; constructor(options: InstanceOptions) { // from options this.bucketKeySeparator = options.bucketKeySeparator || DEFAULT_BUCKET_KEY_SEPARATOR; this.configureBucketKey = options.configureBucketKey; this.configureBucketValue = options.configureBucketValue; this.datafileUrl = options.datafileUrl; this.handleDatafileFetch = options.handleDatafileFetch; this.initialFeatures = options.initialFeatures; this.interceptContext = options.interceptContext; this.logger = options.logger || createLogger(); this.refreshInterval = options.refreshInterval; this.stickyFeatures = options.stickyFeatures; // internal this.emitter = new Emitter(); this.statuses = { ready: false, refreshInProgress: false, }; // register events if (options.onReady) { this.emitter.addListener("ready", options.onReady); } if (options.onRefresh) { this.emitter.addListener("refresh", options.onRefresh); } if (options.onUpdate) { this.emitter.addListener("update", options.onUpdate); } if (options.onActivation) { this.emitter.addListener("activation", options.onActivation); } // expose emitter methods const on = this.emitter.addListener.bind(this.emitter); this.on = on; this.addListener = on; const off = this.emitter.removeListener.bind(this.emitter); this.off = off; this.removeListener = off; this.removeAllListeners = this.emitter.removeAllListeners.bind(this.emitter); // datafile if (options.datafileUrl) { this.setDatafile(options.datafile || emptyDatafile); fetchDatafileContent(options.datafileUrl, options.handleDatafileFetch) .then((datafile) => { this.setDatafile(datafile); this.statuses.ready = true; this.emitter.emit("ready"); if (this.refreshInterval) { this.startRefreshing(); } }) .catch((e) => { this.logger.error("failed to fetch datafile", { error: e }); }); } else if (options.datafile) { this.setDatafile(options.datafile); this.statuses.ready = true; setTimeout(() => { this.emitter.emit("ready"); }, 0); } else { throw new Error( "Featurevisor SDK instance cannot be created without both `datafile` and `datafileUrl` options", ); } } setLogLevels(levels: LogLevel[]) { this.logger.setLevels(levels); } onReady(): Promise<FeaturevisorInstance> { return new Promise((resolve) => { if (this.statuses.ready) { return resolve(this); } const cb = () => { this.emitter.removeListener("ready", cb); resolve(this); }; this.emitter.addListener("ready", cb); }); } setDatafile(datafile: DatafileContent | string) { try { this.datafileReader = new DatafileReader( typeof datafile === "string" ? JSON.parse(datafile) : datafile, ); } catch (e) { this.logger.error("could not parse datafile", { error: e }); } } setStickyFeatures(stickyFeatures: StickyFeatures | undefined) { this.stickyFeatures = stickyFeatures; } getRevision(): string { return this.datafileReader.getRevision(); } getFeature(featureKey: string | Feature): Feature | undefined { return typeof featureKey === "string" ? this.datafileReader.getFeature(featureKey) // only key provided : featureKey; // full feature provided } /** * Statuses */ isReady(): boolean { return this.statuses.ready; } /** * Refresh */ refresh() { this.logger.debug("refreshing datafile"); if (this.statuses.refreshInProgress) { return this.logger.warn("refresh in progress, skipping"); } if (!this.datafileUrl) { return this.logger.error("cannot refresh since `datafileUrl` is not provided"); } this.statuses.refreshInProgress = true; fetchDatafileContent(this.datafileUrl, this.handleDatafileFetch) .then((datafile) => { const currentRevision = this.getRevision(); const newRevision = datafile.revision; const isNotSameRevision = currentRevision !== newRevision; this.setDatafile(datafile); this.logger.info("refreshed datafile"); this.emitter.emit("refresh"); if (isNotSameRevision) { this.emitter.emit("update"); } this.statuses.refreshInProgress = false; }) .catch((e) => { this.logger.error("failed to refresh datafile", { error: e }); this.statuses.refreshInProgress = false; }); } startRefreshing() { if (!this.datafileUrl) { return this.logger.error("cannot start refreshing since `datafileUrl` is not provided"); } if (this.intervalId) { return this.logger.warn("refreshing has already started"); } if (!this.refreshInterval) { return this.logger.warn("no `refreshInterval` option provided"); } this.intervalId = setInterval(() => { this.refresh(); }, this.refreshInterval * 1000); } stopRefreshing() { if (!this.intervalId) { return this.logger.warn("refreshing has not started yet"); } clearInterval(this.intervalId); } /** * Flag */ evaluateFlag(featureKey: FeatureKey | Feature, context: Context = {}): Evaluation { return evaluate({ type: "flag", featureKey, context, logger: this.logger, datafileReader: this.datafileReader, statuses: this.statuses, interceptContext: this.interceptContext, stickyFeatures: this.stickyFeatures, initialFeatures: this.initialFeatures, bucketKeySeparator: this.bucketKeySeparator, configureBucketKey: this.configureBucketKey, configureBucketValue: this.configureBucketValue, }); } isEnabled(featureKey: FeatureKey | Feature, context: Context = {}): boolean { try { const evaluation = this.evaluateFlag(featureKey, context); return evaluation.enabled === true; } catch (e) { this.logger.error("isEnabled", { featureKey, error: e }); return false; } } /** * Variation */ evaluateVariation(featureKey: FeatureKey | Feature, context: Context = {}): Evaluation { return evaluate({ type: "variation", featureKey, context, logger: this.logger, datafileReader: this.datafileReader, statuses: this.statuses, interceptContext: this.interceptContext, stickyFeatures: this.stickyFeatures, initialFeatures: this.initialFeatures, bucketKeySeparator: this.bucketKeySeparator, configureBucketKey: this.configureBucketKey, configureBucketValue: this.configureBucketValue, }); } getVariation( featureKey: FeatureKey | Feature, context: Context = {}, ): VariationValue | undefined { try { const evaluation = this.evaluateVariation(featureKey, context); if (typeof evaluation.variationValue !== "undefined") { return evaluation.variationValue; } if (evaluation.variation) { return evaluation.variation.value; } return undefined; } catch (e) { this.logger.error("getVariation", { featureKey, error: e }); return undefined; } } /** * Activate */ activate(featureKey: FeatureKey, context: Context = {}): VariationValue | undefined { try { const evaluation = this.evaluateVariation(featureKey, context); const variationValue = evaluation.variation ? evaluation.variation.value : evaluation.variationValue; if (typeof variationValue === "undefined") { return undefined; } const finalContext = this.interceptContext ? this.interceptContext(context) : context; const captureContext: Context = {}; const attributesForCapturing = this.datafileReader .getAllAttributes() .filter((a) => a.capture === true); attributesForCapturing.forEach((a) => { if (typeof finalContext[a.key] !== "undefined") { captureContext[a.key] = context[a.key]; } }); this.emitter.emit( "activation", featureKey, variationValue, finalContext, captureContext, evaluation, ); return variationValue; } catch (e) { this.logger.error("activate", { featureKey, error: e }); return undefined; } } /** * Variable */ evaluateVariable( featureKey: FeatureKey | Feature, variableKey: VariableKey, context: Context = {}, ): Evaluation { return evaluate({ type: "variable", featureKey, variableKey, context, logger: this.logger, datafileReader: this.datafileReader, statuses: this.statuses, interceptContext: this.interceptContext, stickyFeatures: this.stickyFeatures, initialFeatures: this.initialFeatures, bucketKeySeparator: this.bucketKeySeparator, configureBucketKey: this.configureBucketKey, configureBucketValue: this.configureBucketValue, }); } getVariable( featureKey: FeatureKey | Feature, variableKey: string, context: Context = {}, ): VariableValue | undefined { try { const evaluation = this.evaluateVariable(featureKey, variableKey, context); if (typeof evaluation.variableValue !== "undefined") { if ( evaluation.variableSchema && evaluation.variableSchema.type === "json" && typeof evaluation.variableValue === "string" ) { return JSON.parse(evaluation.variableValue); } return evaluation.variableValue; } return undefined; } catch (e) { this.logger.error("getVariable", { featureKey, variableKey, error: e }); return undefined; } } getVariableBoolean( featureKey: FeatureKey | Feature, variableKey: string, context: Context = {}, ): boolean | undefined { const variableValue = this.getVariable(featureKey, variableKey, context); return getValueByType(variableValue, "boolean") as boolean | undefined; } getVariableString( featureKey: FeatureKey | Feature, variableKey: string, context: Context = {}, ): string | undefined { const variableValue = this.getVariable(featureKey, variableKey, context); return getValueByType(variableValue, "string") as string | undefined; } getVariableInteger( featureKey: FeatureKey | Feature, variableKey: string, context: Context = {}, ): number | undefined { const variableValue = this.getVariable(featureKey, variableKey, context); return getValueByType(variableValue, "integer") as number | undefined; } getVariableDouble( featureKey: FeatureKey | Feature, variableKey: string, context: Context = {}, ): number | undefined { const variableValue = this.getVariable(featureKey, variableKey, context); return getValueByType(variableValue, "double") as number | undefined; } getVariableArray( featureKey: FeatureKey | Feature, variableKey: string, context: Context = {}, ): string[] | undefined { const variableValue = this.getVariable(featureKey, variableKey, context); return getValueByType(variableValue, "array") as string[] | undefined; } getVariableObject<T>( featureKey: FeatureKey | Feature, variableKey: string, context: Context = {}, ): T | undefined { const variableValue = this.getVariable(featureKey, variableKey, context); return getValueByType(variableValue, "object") as T | undefined; } getVariableJSON<T>( featureKey: FeatureKey | Feature, variableKey: string, context: Context = {}, ): T | undefined { const variableValue = this.getVariable(featureKey, variableKey, context); return getValueByType(variableValue, "json") as T | undefined; } } export function createInstance(options: InstanceOptions): FeaturevisorInstance { return new FeaturevisorInstance(options); }