UNPKG

featurehub-javascript-client-sdk

Version:
461 lines (375 loc) 13.4 kB
// prevents circular deps import { FeatureEnvironmentCollection, FeatureState, SSEResultState } from './models'; import { EdgeService } from './edge_service'; import { FeatureHubConfig, fhLog } from './feature_hub_config'; import { InternalFeatureRepository } from './internal_feature_repository'; import { sha256 } from 'cross-sha256'; import * as base64 from '@juanelas/base64'; export interface PollingService { get frequency(): number; poll(): Promise<void>; stop(): void; // the promise returned is a fake promise, it is the responsibility of the outer caller to start the // poll again once this attribute header has changed attributeHeader(header: string): Promise<void>; busy: boolean; } export type FeaturesFunction = (environments: Array<FeatureEnvironmentCollection>) => void; export type PromiseLikeFunction = (value: void | PromiseLike<void>) => void; export type RejectLikeFunction = (response?: any) => void; interface PromiseLikeData { resolve: PromiseLikeFunction; reject: RejectLikeFunction; } export abstract class PollingBase implements PollingService { protected url: string; protected _frequency: number; protected _callback: FeaturesFunction; protected _stopped = false; protected _header?: string; protected _shaHeader: string; protected _etag: string | undefined | null; protected _busy = false; protected _outstandingPromises : Array<PromiseLikeData> = []; protected constructor(url: string, frequency: number, callback: FeaturesFunction) { this.url = url; this._frequency = frequency; this._shaHeader = '0'; this._callback = callback; this._busy = false; } attributeHeader(header: string): Promise<void> { this._header = header; this._shaHeader = (header === undefined || header.length === 0) ? '0' : base64.encode(new sha256().update(header).digest(), true, false); return new Promise((resolve) => { resolve(); }); } public stop(): void { this._stopped = true; } public get frequency(): number { return this._frequency; } public abstract poll(): Promise<void>; /** * Allow the cache control settings on the server override this polling _frequency * @param cacheHeader */ public parseCacheControl(cacheHeader: string | undefined | null) { const maxAge = cacheHeader?.match(/max-age=(\d+)/); if (maxAge) { const newFreq = parseInt(maxAge[1], 10); if (newFreq > 0) { this._frequency = newFreq * 1000; } } } // this is a dead function but if we don't include it // then node will fail // eslint-disable-next-line require-await protected async delayTimer(): Promise<void> { return new Promise(((resolve) => { resolve(); })); } public get busy() { return this._busy; } protected resolveOutstanding() : void { const outstanding = [... this._outstandingPromises]; this._outstandingPromises = []; outstanding.forEach(e => e.resolve()); } public rejectOutstanding(result?: any) { const outstanding = [... this._outstandingPromises]; this._outstandingPromises = []; outstanding.forEach(e => e.reject(result)); } } export interface NodejsOptions { timeout?: number; } export interface BrowserOptions { timeout?: number; } /** * This should never be used directly but we are exporting it because we need it */ export class BrowserPollingService extends PollingBase implements PollingService { private readonly _options: BrowserOptions; private localStorageLastUrl: string | undefined; // override this with a replacement if you need to, for example to add any headers. static httpRequestor = () => { return new XMLHttpRequest(); }; // override this in React Native - for example use AsyncStorage static localStorageRequestor = () => { if (window.localStorage) { return localStorage; } // maybe in React Native or other similar browsery thing. return { getItem: () => null, setItem: () => {} }; }; constructor(options: BrowserOptions, url: string, frequency: number, callback: FeaturesFunction) { super(url, frequency, callback); this._options = options; } private loadLocalState(url: string) { if (url !== this.localStorageLastUrl) { this.localStorageLastUrl = url; const storedData = BrowserPollingService.localStorageRequestor().getItem(url); if (storedData) { try { const data = JSON.parse(storedData); if (data.e) { // save space with short name this._callback(data.e as Array<FeatureEnvironmentCollection>); } } catch (_) { // ignore exception } } } } public poll(): Promise<void> { if (this._busy) { return new Promise((resolve, reject) => { this._outstandingPromises.push({ resolve: resolve, reject: reject } as PromiseLikeData); }); } if (this._stopped) { return new Promise((resolve) => { resolve(); }); } return new Promise((resolve, reject) => { const calculatedUrl = `${this.url}&contextSha=${this._shaHeader}`; // check in case we have a cached copy of it this.loadLocalState(this.url); const req = BrowserPollingService.httpRequestor(); req.open('GET', calculatedUrl); req.setRequestHeader('Content-type', 'application/json'); if (this._etag) { req.setRequestHeader('if-none-match', this._etag); } if (this._header) { req.setRequestHeader('x-featurehub', this._header); } req.send(); req.onreadystatechange = () => { if (req.readyState === 4) { if (req.status === 200 || req.status == 236) { this._etag = req.getResponseHeader('etag'); this.parseCacheControl(req.getResponseHeader('cache-control')); const environments = JSON.parse(req.responseText) as Array<FeatureEnvironmentCollection>; try { BrowserPollingService.localStorageRequestor().setItem(this.url, JSON.stringify({ e: environments })); } catch (_) { fhLog.error('featurehub: unable to cache features'); } this._callback(environments); this._stopped = (req.status === 236); this._busy = false; this.resolveOutstanding(); resolve(); } else if (req.status == 304) { // no change this._busy = false; this.resolveOutstanding(); resolve(); } else { this._busy = false; this.rejectOutstanding(req.status); reject(req.status); } } }; }); } } export type PollingClientProvider = (options: BrowserOptions, url: string, frequency: number, callback: FeaturesFunction) => PollingService; export class FeatureHubPollingClient implements EdgeService { private readonly _frequency: number; private readonly _url: string; private _repository: InternalFeatureRepository; private _pollingService: PollingService | undefined; private readonly _options: BrowserOptions | NodejsOptions; private _startable: boolean; private readonly _config: FeatureHubConfig; private _xHeader: string | undefined; private _pollPromiseResolve: ((value: (PromiseLike<void> | void)) => void) | undefined; private _pollPromiseReject: ((reason?: any) => void) | undefined; private _currentTimer: any; public static pollingClientProvider: PollingClientProvider = (opt, url, freq, callback) => new BrowserPollingService(opt, url, freq, callback); constructor(repository: InternalFeatureRepository, config: FeatureHubConfig, frequency: number, options: BrowserOptions | NodejsOptions = {}) { this._startable = true; this._frequency = frequency; this._repository = repository; this._options = options; this._config = config; this._url = config.getHost() + 'features?' + config.getApiKeys().map(e => 'apiKey=' + encodeURIComponent(e)).join('&'); } private _initService(): void { if (this._pollingService === undefined && this._startable) { this._pollingService = FeatureHubPollingClient.pollingClientProvider(this._options, this._url, this._frequency, (e) => this.response(e)); fhLog.trace(`featurehub: initialized polling client to ${this._url}`); } } public async contextChange(header: string): Promise<void> { if (!this._config.clientEvaluated()) { if (this._xHeader !== header) { this._xHeader = header; this._initService(); if (this._pollingService) { await this._pollingService.attributeHeader(header); } this._restartTimer(); } } return new Promise<void>((resolve) => resolve()); } public clientEvaluated(): boolean { return this._config.clientEvaluated(); } public requiresReplacementOnHeaderChange(): boolean { return false; } public close(): void { this.stop(); } private stop() { fhLog.trace('polling stopping'); // stop the timeout if one is going on if (this._currentTimer) { clearTimeout(this._currentTimer); this._currentTimer = undefined; } if (this._pollPromiseReject !== undefined) { this._pollPromiseReject('Never came live'); } // stop the polling service and clear it this._pollingService?.stop(); this._pollingService = undefined; } public poll(): Promise<void> { if (this._pollPromiseResolve !== undefined || this._pollingService?.busy) { return new Promise<void>((resolve) => resolve()); } if (!this._startable) { return new Promise<void>((_, reject) => reject()); } this._initService(); return new Promise<void>((resolve, reject) => { this._pollPromiseReject = reject; this._pollPromiseResolve = resolve; this._restartTimer(); }); } public get canStart() { return this._startable; } public get pollingFrequency() : undefined|number { return this._pollingService?.frequency; } public get active() : boolean { return this._pollingService?.busy || this._currentTimer !== undefined; } public get awaitingFirstSuccess(): boolean { return this._pollPromiseReject !== undefined; } private _restartTimer() { if (this._pollingService === undefined || this._pollingService?.busy || !this._startable) { return; } fhLog.trace('polling restarting'); if (this._currentTimer) { clearTimeout(this._currentTimer); this._currentTimer = undefined; } this._pollFunc(); } private _pollFunc() { this._pollingService!.poll() .then(() => { fhLog.trace('poll successful'); // set the next one going before we resolve as otherwise the test will fail this._readyNextPoll(); if (this._pollPromiseResolve !== undefined) { try { this._pollPromiseResolve(); } catch (e) { fhLog.error('Failed to process resolve', e); } } this._pollPromiseReject = undefined; this._pollPromiseResolve = undefined; }) .catch((status) => { fhLog.trace('poll failed', status); if (status === 404 || status == 400) { if (status == 404) { fhLog.error('The API Key provided does not exist, stopping polling.'); } this._repository.notify(SSEResultState.Failure, null); this._startable = false; this.stop(); if (this._pollPromiseReject) { try { this._pollPromiseReject(status); } catch (e) { fhLog.error('Failed to process reject', e); } } this._pollPromiseReject = undefined; this._pollPromiseResolve = undefined; } else { this._readyNextPoll(); if (status == 503) { fhLog.log('The backend is not ready, waiting for the next poll.'); } } }).finally(() => { }); } private _readyNextPoll() { if (this._pollingService && this._pollingService.frequency > 0) { // in case we got a 404, and it was shut down fhLog.trace('starting timer for poll', this._pollingService.frequency); this._currentTimer = setTimeout(() => this._restartTimer(), this._pollingService.frequency); } else { fhLog.trace('no polling service or 0 frequency, stopping polling.', this._pollingService === undefined, this._pollingService?.frequency); } } private response(environments: Array<FeatureEnvironmentCollection>): void { if (environments.length === 0) { this._startable = false; this.stop(); this._repository.notify(SSEResultState.Failure, null); } else { const features = new Array<FeatureState>(); environments.forEach(e => { if (e.features!.length > 0) { // set the environment id so each feature knows which environment it comes from e.features!.forEach(f => { f.environmentId = e.id; }); features.push(...e.features!); } }); this._repository.notify(SSEResultState.Features, features); } } }