featurehub-javascript-client-sdk
Version:
FeatureHub client/browser SDK
376 lines (315 loc) • 13 kB
text/typescript
import { FeatureStateBaseHolder } from './feature_state_holders';
import { FeatureStateValueInterceptor, InterceptorValueMatch } from './interceptors';
import { FeatureStateHolder } from './feature_state';
import { AnalyticsCollector } from './analytics';
// leave this here, prevents circular deps
import { FeatureRolloutStrategy, FeatureState, FeatureValueType, SSEResultState } from './models';
import { ClientContext } from './client_context';
import { Applied, ApplyFeature } from './strategy_matcher';
import { InternalFeatureRepository } from './internal_feature_repository';
import { CatchReleaseListenerHandler, fhLog, ReadinessListenerHandle } from './feature_hub_config';
import { PostLoadNewFeatureStateAvailableListener, Readyness, ReadynessListener } from './featurehub_repository';
import { ListenerUtils } from './listener_utils';
export class ClientFeatureRepository implements InternalFeatureRepository {
private hasReceivedInitialState = false;
// indexed by key as that what the user cares about
private features = new Map<string, FeatureStateBaseHolder>();
private analyticsCollectors = new Array<AnalyticsCollector>();
private readynessState: Readyness = Readyness.NotReady;
private _readinessListeners: Map<number, ReadynessListener> = new Map<number, ReadynessListener>();
private _catchAndReleaseMode = false;
// indexed by id
private _catchReleaseStates = new Map<string, FeatureState>();
private _newFeatureStateAvailableListeners: Map<number, PostLoadNewFeatureStateAvailableListener> = new Map<number, PostLoadNewFeatureStateAvailableListener>();
private _matchers: Array<FeatureStateValueInterceptor> = [];
private readonly _applyFeature: ApplyFeature;
private _catchReleaseCheckForDeletesOnRelease?: FeatureState[];
constructor(applyFeature?: ApplyFeature) {
this._applyFeature = applyFeature || new ApplyFeature();
}
public apply(strategies: Array<FeatureRolloutStrategy>, key: string, featureValueId: string,
context: ClientContext): Applied {
return this._applyFeature.apply(strategies, key, featureValueId, context);
}
public get readyness(): Readyness {
return this.readynessState;
}
public notify(state: SSEResultState, data: any) {
if (state !== null && state !== undefined) {
switch (state) {
case SSEResultState.Ack: // do nothing, expect state shortly
case SSEResultState.Bye: // do nothing, we expect a reconnection shortly
break;
case SSEResultState.DeleteFeature:
this.deleteFeature(data);
break;
case SSEResultState.Failure:
this.readynessState = Readyness.Failed;
if (!this._catchAndReleaseMode) {
this.broadcastReadynessState(false);
}
break;
case SSEResultState.Feature: {
const fs = data as FeatureState;
if (this._catchAndReleaseMode) {
this._catchUpdatedFeatures([fs], false);
} else {
if (this.featureUpdate(fs)) {
this.triggerNewStateAvailable();
}
}
}
break;
case SSEResultState.Features: {
const features = (data as []).filter((f:any) => f?.key !== undefined).map((f : any) => f as FeatureState);
if (this.hasReceivedInitialState && this._catchAndReleaseMode) {
this._catchUpdatedFeatures(features, true);
} else {
let updated = false;
features.forEach((f) => updated = this.featureUpdate(f) || updated);
this._checkForDeletedFeatures(features);
this.readynessState = Readyness.Ready;
if (!this.hasReceivedInitialState) {
this.hasReceivedInitialState = true;
this.broadcastReadynessState(true);
} else if (updated) {
this.triggerNewStateAvailable();
}
}
}
break;
default:
break;
}
}
}
/**
* We have a whole list of all the features come in, we need to make sure that none of the
* features we have been deleted. If they have, we need to remove them like we received
* a delete.
*
* @param features
* @private
*/
private _checkForDeletedFeatures(features: FeatureState[]) {
const featureMatch = new Map(this.features);
features.forEach(f => featureMatch.delete(f.key));
if (featureMatch.size > 0) {
for (const k of featureMatch.keys()) {
this.deleteFeature({ key: k } as FeatureState);
}
}
}
public addValueInterceptor(matcher: FeatureStateValueInterceptor): void {
this._matchers.push(matcher);
matcher.repository(this);
}
public valueInterceptorMatched(key: string): InterceptorValueMatch | undefined {
for (const matcher of this._matchers) {
const m = matcher.matched(key);
if (m?.value) {
return m;
}
}
return undefined;
}
public addPostLoadNewFeatureStateAvailableListener(listener: PostLoadNewFeatureStateAvailableListener): CatchReleaseListenerHandler {
const pos = ListenerUtils.newListenerKey(this._newFeatureStateAvailableListeners);
this._newFeatureStateAvailableListeners.set(pos, listener);
if (this._catchReleaseStates.size > 0) {
listener(this);
}
return pos;
}
public removePostLoadNewFeatureStateAvailableListener(listener: PostLoadNewFeatureStateAvailableListener | CatchReleaseListenerHandler) {
ListenerUtils.removeListener(this._newFeatureStateAvailableListeners, listener);
}
public addReadynessListener(listener: ReadynessListener): ReadinessListenerHandle {
return this.addReadinessListener(listener);
}
public addReadinessListener(listener: ReadynessListener, ignoreNotReadyOnRegister?: boolean): ReadinessListenerHandle {
const pos = ListenerUtils.newListenerKey(this._readinessListeners);
this._readinessListeners.set(pos, listener);
if (!ignoreNotReadyOnRegister || (ignoreNotReadyOnRegister && this.readynessState != Readyness.NotReady)) {
// always let them know what it is in case its already ready
listener(this.readynessState, this.hasReceivedInitialState);
}
return pos;
}
removeReadinessListener(listener: ReadynessListener | ReadinessListenerHandle) {
ListenerUtils.removeListener(this._readinessListeners, listener);
}
notReady(): void {
this.readynessState = Readyness.NotReady;
this.broadcastReadynessState(false);
}
public broadcastReadynessState(firstState: boolean): void {
this._readinessListeners.forEach((l) => l(this.readynessState, firstState));
}
public addAnalyticCollector(collector: AnalyticsCollector): void {
this.analyticsCollectors.push(collector);
}
public simpleFeatures(): Map<string, string | undefined> {
const vals = new Map<string, string | undefined>();
this.features.forEach((value, key) => {
if (value.exists) { // only include valid features
let val: any;
switch (value.getType()) {// we need to pick up any overrides
case FeatureValueType.Boolean:
val = value.flag ? 'true' : 'false';
break;
case FeatureValueType.String:
val = value.str;
break;
case FeatureValueType.Number:
val = value.num;
break;
case FeatureValueType.Json:
val = value.rawJson;
break;
default:
val = undefined;
}
vals.set(key, val === undefined ? val : val.toString());
}
});
return vals;
}
public logAnalyticsEvent(action: string, other?: Map<string, string>, ctx?: ClientContext): void {
const featureStateAtCurrentTime: Array<FeatureStateBaseHolder> = [];
for (const fs of this.features.values()) {
if (fs.isSet()) {
const fsVal: FeatureStateBaseHolder = ctx == null ? fs : fs.withContext(ctx) as FeatureStateBaseHolder;
featureStateAtCurrentTime.push(fsVal.analyticsCopy());
}
}
this.analyticsCollectors.forEach((ac) => ac.logEvent(action, other || new Map<string, string>(),
featureStateAtCurrentTime));
}
public hasFeature(key: string): undefined | FeatureStateHolder {
return this.features.get(key);
}
public feature<T = any>(key: string): FeatureStateHolder<T> {
let holder = this.features.get(key);
if (holder === undefined) {
holder = new FeatureStateBaseHolder<T>(this, key);
this.features.set(key, holder);
}
return holder;
}
// deprecated
public getFeatureState<T = any>(key: string): FeatureStateHolder<T> {
return this.feature(key);
}
get catchAndReleaseMode(): boolean {
return this._catchAndReleaseMode;
}
set catchAndReleaseMode(value: boolean) {
if (this._catchAndReleaseMode !== value && !value) {
this.release(true);
}
this._catchAndReleaseMode = value;
}
// eslint-disable-next-line require-await
public async release(disableCatchAndRelease?: boolean): Promise<void> {
while (this._catchReleaseStates.size > 0 || this._catchReleaseCheckForDeletesOnRelease !== undefined) {
const states = [...this._catchReleaseStates.values()];
this._catchReleaseStates.clear(); // remove all existing items
states.forEach((fs) => this.featureUpdate(fs));
if (this._catchReleaseCheckForDeletesOnRelease) {
this._checkForDeletedFeatures(this._catchReleaseCheckForDeletesOnRelease);
this._catchReleaseCheckForDeletesOnRelease = undefined;
}
}
if (disableCatchAndRelease === true) {
this._catchAndReleaseMode = false;
}
}
public getFlag(key: string): boolean | undefined {
return this.feature(key).getFlag();
}
public getString(key: string): string | undefined {
return this.feature(key).getString();
}
public getJson(key: string): string | undefined {
return this.feature(key).getRawJson();
}
public getNumber(key: string): number | undefined {
return this.feature(key).getNumber();
}
public isSet(key: string): boolean {
return this.feature(key).isSet();
}
private _catchUpdatedFeatures(features: FeatureState[], isFullList: boolean) {
let updatedValues = false;
if (isFullList) {
// we have to keep track of all of them because we need to know which ones to delete
// and the catch release state needs to keep track of only the latest version and make sure
// it updates the right data
this._catchReleaseCheckForDeletesOnRelease = features;
}
if (features && features.length > 0) {
features.forEach((f) => {
const existingFeature = this.features.get(f.key);
if (!existingFeature || !existingFeature.exists || (existingFeature.getKey()
&& f.version! > (existingFeature.getFeatureState()?.version || -1))) {
const fs = this._catchReleaseStates.get(f.id);
if (fs == null) {
this._catchReleaseStates.set(f.id, f);
updatedValues = true;
} else {
// check it is newer
if (fs.version === undefined || (f.version !== undefined && f.version > fs.version)) {
this._catchReleaseStates.set(f.id, f);
updatedValues = true;
}
}
}
});
}
if (updatedValues) {
this.triggerNewStateAvailable();
}
}
private triggerNewStateAvailable(): void {
if (this.hasReceivedInitialState && this._newFeatureStateAvailableListeners.size > 0) {
if (!this._catchAndReleaseMode || (this._catchReleaseStates.size > 0)) {
this._newFeatureStateAvailableListeners.forEach((l) => {
try {
l(this);
} catch (e) {
fhLog.log('failed', e);
}
});
}
} else {
// console.log('new data, no listeners');
}
}
private featureUpdate(fs: FeatureState): boolean {
if (fs === undefined || fs.key === undefined) {
return false;
}
let holder = this.features.get(fs.key);
if (holder === undefined) {
const newFeature = new FeatureStateBaseHolder(this, fs.key, holder);
this.features.set(fs.key, newFeature);
holder = newFeature;
} else if (holder.getFeatureState() !== undefined) {
const fState = holder.getFeatureState()!;
if (fs.version! < fState.version!) {
return false;
}
}
return holder.setFeatureState(fs);
}
private deleteFeature(featureState: FeatureState) {
const holder = this.features.get(featureState.key);
// because of parallelism we can receive retired features after they have been unretired
// so we need to check their versions. An actual deleted feature however will have a version of 0
// and a feature value created as retired will also have a version of zero.
if (holder && ((featureState.version === undefined) || (featureState.version === 0) || (featureState.version >= holder.version))) {
holder.setFeatureState(undefined);
}
}
}