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; }; const _globalThis = globalThis as OpenFeatureGlobal; export class OpenFeatureAPI extends OpenFeatureCommonAPI implements ManageContext> { protected _statusEnumType: typeof ProviderStatus = ProviderStatus; protected _apiEmitter: GenericEventEmitter = new OpenFeatureEventEmitter(); protected _defaultProvider: ProviderWrapper = new ProviderWrapper( NOOP_PROVIDER, ProviderStatus.NOT_READY, this._statusEnumType, ); protected _domainScopedProviders: Map> = 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} * @throws {Error} If the provider throws an exception during initialization. */ setProviderAndWait(provider: Provider): Promise; /** * 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} * @throws {Error} If the provider throws an exception during initialization. */ setProviderAndWait(provider: Provider, context: EvaluationContext): Promise; /** * 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} * @throws {Error} If the provider throws an exception during initialization. */ setProviderAndWait(domain: string, provider: Provider): Promise; /** * 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} * @throws {Error} If the provider throws an exception during initialization. */ setProviderAndWait(domain: string, provider: Provider, context: EvaluationContext): Promise; async setProviderAndWait( clientOrProvider?: string | Provider, providerContextOrUndefined?: Provider | EvaluationContext, contextOrUndefined?: EvaluationContext, ): Promise { const domain = stringOrUndefined(clientOrProvider); const provider = domain ? objectOrUndefined(providerContextOrUndefined) : objectOrUndefined(clientOrProvider); const context = domain ? objectOrUndefined(contextOrUndefined) : objectOrUndefined(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(providerContextOrUndefined) : objectOrUndefined(domainOrProvider); const context = domain ? objectOrUndefined(contextOrUndefined) : objectOrUndefined(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; /** * 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; async setContext(domainOrContext: T | string, contextOrUndefined?: T): Promise { const domain = stringOrUndefined(domainOrContext); const context = objectOrUndefined(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((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; /** * Removes the evaluation context for a specific named client. * @param {string} domain An identifier which logically binds clients with providers */ clearContext(domain: string): Promise; async clearContext(domainOrUndefined?: string): Promise { 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 { // 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} */ async clearProviders(): Promise { await super.clearProvidersAndSetDefault(NOOP_PROVIDER); this._domainScopedContext.clear(); } private async runProviderContextChangeHandler( domain: string | undefined, wrapper: ProviderWrapper, oldContext: EvaluationContext, newContext: EvaluationContext, ): Promise { // 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();