js-sdk/packages/web/src/open-feature.ts

438 lines
18 KiB
TypeScript

import type {
ClientProviderStatus,
EvaluationContext,
GenericEventEmitter,
ManageContext} from '@openfeature/core';
import {
OpenFeatureCommonAPI,
ProviderWrapper,
objectOrUndefined,
stringOrUndefined,
} from '@openfeature/core';
import type { Client } from './client';
import { OpenFeatureClient } from './client/internal/open-feature-client';
import { OpenFeatureEventEmitter, ProviderEvents } from './events';
import type { Hook } from './hooks';
import type { Provider} from './provider';
import { NOOP_PROVIDER, ProviderStatus } from './provider';
// use a symbol as a key for the global singleton
const GLOBAL_OPENFEATURE_API_KEY = Symbol.for('@openfeature/web-sdk/api');
type OpenFeatureGlobal = {
[GLOBAL_OPENFEATURE_API_KEY]?: OpenFeatureAPI;
};
type DomainRecord = {
domain?: string;
wrapper: ProviderWrapper<Provider, ClientProviderStatus>;
};
const _globalThis = globalThis as OpenFeatureGlobal;
export class OpenFeatureAPI
extends OpenFeatureCommonAPI<ClientProviderStatus, Provider, Hook>
implements ManageContext<Promise<void>>
{
protected _statusEnumType: typeof ProviderStatus = ProviderStatus;
protected _apiEmitter: GenericEventEmitter<ProviderEvents> = new OpenFeatureEventEmitter();
protected _defaultProvider: ProviderWrapper<Provider, ClientProviderStatus> = new ProviderWrapper(
NOOP_PROVIDER,
ProviderStatus.NOT_READY,
this._statusEnumType,
);
protected _domainScopedProviders: Map<string, ProviderWrapper<Provider, ClientProviderStatus>> = new Map();
protected _createEventEmitter = () => new OpenFeatureEventEmitter();
private constructor() {
super('client');
}
/**
* Gets a singleton instance of the OpenFeature API.
* @ignore
* @returns {OpenFeatureAPI} OpenFeature API
*/
static getInstance(): OpenFeatureAPI {
const globalApi = _globalThis[GLOBAL_OPENFEATURE_API_KEY];
if (globalApi) {
return globalApi;
}
const instance = new OpenFeatureAPI();
_globalThis[GLOBAL_OPENFEATURE_API_KEY] = instance;
return instance;
}
private getProviderStatus(domain?: string): ProviderStatus {
if (!domain) {
return this._defaultProvider.status;
}
return this._domainScopedProviders.get(domain)?.status ?? this._defaultProvider.status;
}
/**
* Sets the default provider for flag evaluations and returns a promise that resolves when the provider is ready.
* This provider will be used by domainless clients and clients associated with domains to which no provider is bound.
* Setting a provider supersedes the current provider used in new and existing unbound clients.
* @param {Provider} provider The provider responsible for flag evaluations.
* @returns {Promise<void>}
* @throws {Error} If the provider throws an exception during initialization.
*/
setProviderAndWait(provider: Provider): Promise<void>;
/**
* Sets the default provider for flag evaluations and returns a promise that resolves when the provider is ready.
* This provider will be used by domainless clients and clients associated with domains to which no provider is bound.
* Setting a provider supersedes the current provider used in new and existing unbound clients.
* @param {Provider} provider The provider responsible for flag evaluations.
* @param {EvaluationContext} context The evaluation context to use for flag evaluations.
* @returns {Promise<void>}
* @throws {Error} If the provider throws an exception during initialization.
*/
setProviderAndWait(provider: Provider, context: EvaluationContext): Promise<void>;
/**
* Sets the provider that OpenFeature will use for flag evaluations on clients bound to the same domain.
* A promise is returned that resolves when the provider is ready.
* Setting a provider supersedes the current provider used in new and existing clients bound to the same domain.
* @param {string} domain The name to identify the client
* @param {Provider} provider The provider responsible for flag evaluations.
* @returns {Promise<void>}
* @throws {Error} If the provider throws an exception during initialization.
*/
setProviderAndWait(domain: string, provider: Provider): Promise<void>;
/**
* Sets the provider that OpenFeature will use for flag evaluations on clients bound to the same domain.
* A promise is returned that resolves when the provider is ready.
* Setting a provider supersedes the current provider used in new and existing clients bound to the same domain.
* @param {string} domain The name to identify the client
* @param {Provider} provider The provider responsible for flag evaluations.
* @param {EvaluationContext} context The evaluation context to use for flag evaluations.
* @returns {Promise<void>}
* @throws {Error} If the provider throws an exception during initialization.
*/
setProviderAndWait(domain: string, provider: Provider, context: EvaluationContext): Promise<void>;
async setProviderAndWait(
clientOrProvider?: string | Provider,
providerContextOrUndefined?: Provider | EvaluationContext,
contextOrUndefined?: EvaluationContext,
): Promise<void> {
const domain = stringOrUndefined(clientOrProvider);
const provider = domain
? objectOrUndefined<Provider>(providerContextOrUndefined)
: objectOrUndefined<Provider>(clientOrProvider);
const context = domain
? objectOrUndefined<EvaluationContext>(contextOrUndefined)
: objectOrUndefined<EvaluationContext>(providerContextOrUndefined);
if (context) {
// synonymously setting context prior to provider initialization.
// No context change event will be emitted.
if (domain) {
this._domainScopedContext.set(domain, context);
} else {
this._context = context;
}
}
await this.setAwaitableProvider(domain, provider);
}
/**
* Sets the default provider for flag evaluations.
* This provider will be used by domainless clients and clients associated with domains to which no provider is bound.
* Setting a provider supersedes the current provider used in new and existing unbound clients.
* @param {Provider} provider The provider responsible for flag evaluations.
* @returns {this} OpenFeature API
*/
setProvider(provider: Provider): this;
/**
* Sets the default provider and evaluation context for flag evaluations.
* This provider will be used by domainless clients and clients associated with domains to which no provider is bound.
* Setting a provider supersedes the current provider used in new and existing unbound clients.
* @param {Provider} provider The provider responsible for flag evaluations.
* @param context {EvaluationContext} The evaluation context to use for flag evaluations.
* @returns {this} OpenFeature API
*/
setProvider(provider: Provider, context: EvaluationContext): this;
/**
* Sets the provider for flag evaluations of providers with the given name.
* Setting a provider supersedes the current provider used in new and existing clients bound to the same domain.
* @param {string} domain The name to identify the client
* @param {Provider} provider The provider responsible for flag evaluations.
* @returns {this} OpenFeature API
*/
setProvider(domain: string, provider: Provider): this;
/**
* Sets the provider and evaluation context flag evaluations of providers with the given name.
* Setting a provider supersedes the current provider used in new and existing clients bound to the same domain.
* @param {string} domain The name to identify the client
* @param {Provider} provider The provider responsible for flag evaluations.
* @param context {EvaluationContext} The evaluation context to use for flag evaluations.
* @returns {this} OpenFeature API
*/
setProvider(domain: string, provider: Provider, context: EvaluationContext): this;
setProvider(
domainOrProvider?: string | Provider,
providerContextOrUndefined?: Provider | EvaluationContext,
contextOrUndefined?: EvaluationContext,
): this {
const domain = stringOrUndefined(domainOrProvider);
const provider = domain
? objectOrUndefined<Provider>(providerContextOrUndefined)
: objectOrUndefined<Provider>(domainOrProvider);
const context = domain
? objectOrUndefined<EvaluationContext>(contextOrUndefined)
: objectOrUndefined<EvaluationContext>(providerContextOrUndefined);
if (context) {
// synonymously setting context prior to provider initialization.
// No context change event will be emitted.
if (domain) {
this._domainScopedContext.set(domain, context);
} else {
this._context = context;
}
}
const maybePromise = this.setAwaitableProvider(domain, provider);
// The setProvider method doesn't return a promise so we need to catch and
// log any errors that occur during provider initialization to avoid having
// an unhandled promise rejection.
Promise.resolve(maybePromise).catch((err) => {
this._logger.error('Error during provider initialization:', err);
});
return this;
}
/**
* Get the default provider.
*
* Note that it isn't recommended to interact with the provider directly, but rather through
* an OpenFeature client.
* @returns {Provider} Default Provider
*/
getProvider(): Provider;
/**
* Get the provider bound to the specified domain.
*
* Note that it isn't recommended to interact with the provider directly, but rather through
* an OpenFeature client.
* @param {string} domain An identifier which logically binds clients with providers
* @returns {Provider} Domain-scoped provider
*/
getProvider(domain?: string): Provider;
getProvider(domain?: string): Provider {
return this.getProviderForClient(domain);
}
/**
* Sets the evaluation context globally.
* This will be used by all providers that have not bound to a domain.
* @param {EvaluationContext} context Evaluation context
* @example
* await OpenFeature.setContext({ region: "us" });
*/
async setContext(context: EvaluationContext): Promise<void>;
/**
* Sets the evaluation context for a specific provider.
* This will only affect providers bound to a domain.
* @param {string} domain An identifier which logically binds clients with providers
* @param {EvaluationContext} context Evaluation context
* @example
* await OpenFeature.setContext("test", { scope: "provider" });
* OpenFeature.setProvider(new MyProvider()) // Uses the default context
* OpenFeature.setProvider("test", new MyProvider()) // Uses context: { scope: "provider" }
*/
async setContext(domain: string, context: EvaluationContext): Promise<void>;
async setContext<T extends EvaluationContext>(domainOrContext: T | string, contextOrUndefined?: T): Promise<void> {
const domain = stringOrUndefined(domainOrContext);
const context = objectOrUndefined<T>(domainOrContext) ?? objectOrUndefined(contextOrUndefined) ?? {};
if (domain) {
const wrapper = this._domainScopedProviders.get(domain);
if (wrapper) {
const oldContext = this.getContext(domain);
this._domainScopedContext.set(domain, context);
await this.runProviderContextChangeHandler(domain, wrapper, oldContext, context);
} else {
this._domainScopedContext.set(domain, context);
}
} else {
const oldContext = this._context;
this._context = context;
// collect all providers that are using the default context (not bound to a domain)
const unboundProviders: DomainRecord[] = Array.from(this._domainScopedProviders.entries())
.filter(([domain]) => !this._domainScopedContext.has(domain))
.reduce<DomainRecord[]>((acc, [domain, wrapper]) => {
acc.push({ domain, wrapper });
return acc;
}, []);
const allDomainRecords: DomainRecord[] = [
// add in the default (no domain)
{ domain: undefined, wrapper: this._defaultProvider },
...unboundProviders,
];
await Promise.all(
allDomainRecords.map((dm) => this.runProviderContextChangeHandler(dm.domain, dm.wrapper, oldContext, context)),
);
}
}
/**
* Access the global evaluation context.
* @returns {EvaluationContext} Evaluation context
*/
getContext(): EvaluationContext;
/**
* Access the evaluation context for a specific named client.
* The global evaluation context is returned if a matching named client is not found.
* @param {string} domain An identifier which logically binds clients with providers
* @returns {EvaluationContext} Evaluation context
*/
getContext(domain?: string | undefined): EvaluationContext;
getContext(domainOrUndefined?: string): EvaluationContext {
const domain = stringOrUndefined(domainOrUndefined);
if (domain) {
const context = this._domainScopedContext.get(domain);
if (context) {
return context;
} else {
this._logger.debug(`Unable to find context for '${domain}'.`);
}
}
return this._context;
}
/**
* Resets the global evaluation context to an empty object.
*/
clearContext(): Promise<void>;
/**
* Removes the evaluation context for a specific named client.
* @param {string} domain An identifier which logically binds clients with providers
*/
clearContext(domain: string): Promise<void>;
async clearContext(domainOrUndefined?: string): Promise<void> {
const domain = stringOrUndefined(domainOrUndefined);
if (domain) {
const wrapper = this._domainScopedProviders.get(domain);
if (wrapper) {
const oldContext = this.getContext(domain);
this._domainScopedContext.delete(domain);
const newContext = this.getContext();
await this.runProviderContextChangeHandler(domain, wrapper, oldContext, newContext);
} else {
this._domainScopedContext.delete(domain);
}
} else {
return this.setContext({});
}
}
/**
* Resets the global evaluation context and removes the evaluation context for
* all domains.
*/
async clearContexts(): Promise<void> {
// Default context must be cleared first to avoid calling the onContextChange
// handler multiple times for clients bound to a domain.
await this.clearContext();
// Use allSettled so a promise rejection doesn't affect others
await Promise.allSettled(Array.from(this._domainScopedProviders.keys()).map((domain) => this.clearContext(domain)));
}
/**
* A factory function for creating new domain-scoped OpenFeature clients. Clients
* can contain their own state (e.g. logger, hook, context). Multiple domains
* can be used to segment feature flag configuration.
*
* If there is already a provider bound to this name via {@link this.setProvider setProvider}, this provider will be used.
* Otherwise, the default provider is used until a provider is assigned to that name.
* @param {string} domain An identifier which logically binds clients with providers
* @param {string} version The version of the client (only used for metadata)
* @returns {Client} OpenFeature Client
*/
getClient(domain?: string, version?: string): Client {
return new OpenFeatureClient(
// functions are passed here to make sure that these values are always up to date,
// and so we don't have to make these public properties on the API class.
() => this.getProviderForClient(domain),
() => this.getProviderStatus(domain),
() => this.buildAndCacheEventEmitterForClient(domain),
(domain?: string) => this.getContext(domain),
() => this.getHooks(),
() => this._logger,
{ domain, version },
);
}
/**
* Clears all registered providers and resets the default provider.
* @returns {Promise<void>}
*/
async clearProviders(): Promise<void> {
await super.clearProvidersAndSetDefault(NOOP_PROVIDER);
this._domainScopedContext.clear();
}
private async runProviderContextChangeHandler(
domain: string | undefined,
wrapper: ProviderWrapper<Provider, ClientProviderStatus>,
oldContext: EvaluationContext,
newContext: EvaluationContext,
): Promise<void> {
// this should always be set according to the typings, but let's be defensive considering JS
const providerName = wrapper.provider?.metadata?.name || 'unnamed-provider';
try {
if (typeof wrapper.provider.onContextChange === 'function') {
const maybePromise = wrapper.provider.onContextChange(oldContext, newContext);
// only reconcile if the onContextChange method returns a promise
if (typeof maybePromise?.then === 'function') {
wrapper.incrementPendingContextChanges();
wrapper.status = this._statusEnumType.RECONCILING;
this.getAssociatedEventEmitters(domain).forEach((emitter) => {
emitter?.emit(ProviderEvents.Reconciling, { domain, providerName });
});
this._apiEmitter?.emit(ProviderEvents.Reconciling, { domain, providerName });
await maybePromise;
wrapper.decrementPendingContextChanges();
}
}
// only run the event handlers, and update the state if the onContextChange method succeeded
wrapper.status = this._statusEnumType.READY;
if (wrapper.allContextChangesSettled) {
this.getAssociatedEventEmitters(domain).forEach((emitter) => {
emitter?.emit(ProviderEvents.ContextChanged, { clientName: domain, domain, providerName });
});
this._apiEmitter?.emit(ProviderEvents.ContextChanged, { clientName: domain, domain, providerName });
}
} catch (err) {
// run error handlers instead
wrapper.decrementPendingContextChanges();
wrapper.status = this._statusEnumType.ERROR;
if (wrapper.allContextChangesSettled) {
const error = err as Error | undefined;
const message = `Error running ${providerName}'s context change handler: ${error?.message}`;
this._logger?.error(`${message}`, err);
this.getAssociatedEventEmitters(domain).forEach((emitter) => {
emitter?.emit(ProviderEvents.Error, { clientName: domain, domain, providerName, message });
});
this._apiEmitter?.emit(ProviderEvents.Error, { clientName: domain, domain, providerName, message });
}
}
}
}
/**
* A singleton instance of the OpenFeature API.
* @returns {OpenFeatureAPI} OpenFeature API
*/
export const OpenFeature = OpenFeatureAPI.getInstance();