From 9964c64beaa02a60a296eebc5708dbcf831fae23 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Fri, 2 Jan 2026 14:18:50 -0500 Subject: [PATCH] feat: add isolated (non-singleton) OpenFeature API instances - Add createIsolatedOpenFeatureAPI() via /isolated sub-path export for web and server SDKs - Extract core logic into OpenFeatureAPIBase class, keeping OpenFeatureAPI as thin singleton wrapper - Update React SDK OpenFeatureProvider to accept optional openfeature prop for isolated instances - Add comprehensive tests for isolation of providers, context, hooks, and event handlers Addresses singleton limitations for micro-frontend architectures and testing scenarios. See: https://github.com/open-feature/spec/issues/359 Signed-off-by: Jonathan Norris --- packages/react/src/provider/provider.tsx | 31 +- packages/server/package.json | 21 +- packages/server/rollup.isolated.config.mjs | 20 + packages/server/src/index.ts | 1 + packages/server/src/isolated.ts | 57 +++ packages/server/src/open-feature-base.ts | 243 +++++++++++++ packages/server/src/open-feature.ts | 244 +------------ packages/server/test/isolated.spec.ts | 204 +++++++++++ packages/web/package.json | 21 +- packages/web/rollup.isolated.config.mjs | 20 + packages/web/src/index.ts | 1 + packages/web/src/isolated.ts | 58 +++ packages/web/src/open-feature-base.ts | 403 +++++++++++++++++++++ packages/web/src/open-feature.ts | 403 +-------------------- packages/web/test/isolated.spec.ts | 192 ++++++++++ 15 files changed, 1278 insertions(+), 641 deletions(-) create mode 100644 packages/server/rollup.isolated.config.mjs create mode 100644 packages/server/src/isolated.ts create mode 100644 packages/server/src/open-feature-base.ts create mode 100644 packages/server/test/isolated.spec.ts create mode 100644 packages/web/rollup.isolated.config.mjs create mode 100644 packages/web/src/isolated.ts create mode 100644 packages/web/src/open-feature-base.ts create mode 100644 packages/web/test/isolated.spec.ts diff --git a/packages/react/src/provider/provider.tsx b/packages/react/src/provider/provider.tsx index 21321f24d6..4b095890e1 100644 --- a/packages/react/src/provider/provider.tsx +++ b/packages/react/src/provider/provider.tsx @@ -1,5 +1,5 @@ import { withFrameworkMetadata } from '@openfeature/core'; -import type { Client } from '@openfeature/web-sdk'; +import type { Client, OpenFeatureAPIBase } from '@openfeature/web-sdk'; import { OpenFeature } from '@openfeature/web-sdk'; import * as React from 'react'; import type { ReactFlagEvaluationOptions } from '../options'; @@ -12,6 +12,28 @@ type ClientOrDomain = * @see OpenFeature.setProvider() and overloads. */ domain?: string; + /** + * An isolated OpenFeature API instance to use instead of the global singleton. + * Use this in micro-frontend architectures where different parts of the application + * need isolated OpenFeature instances. + * @see createIsolatedOpenFeatureAPI from '@openfeature/web-sdk/isolated' + * @example + * ```tsx + * import { createIsolatedOpenFeatureAPI } from '@openfeature/web-sdk/isolated'; + * + * const MyOpenFeature = createIsolatedOpenFeatureAPI(); + * MyOpenFeature.setProvider(myProvider); + * + * function App() { + * return ( + * + * {children} + * + * ); + * } + * ``` + */ + openfeature?: OpenFeatureAPIBase; client?: never; } | { @@ -20,6 +42,7 @@ type ClientOrDomain = */ client?: Client; domain?: never; + openfeature?: never; }; type ProviderProps = { @@ -32,10 +55,10 @@ type ProviderProps = { * @param {ProviderProps} properties props for the context provider * @returns {OpenFeatureProvider} context provider */ -export function OpenFeatureProvider({ client, domain, children, ...options }: ProviderProps) { +export function OpenFeatureProvider({ client, domain, openfeature, children, ...options }: ProviderProps) { const stableClient = React.useMemo( - () => withFrameworkMetadata(client || OpenFeature.getClient(domain), 'react'), - [client, domain], + () => withFrameworkMetadata(client || (openfeature ?? OpenFeature).getClient(domain), 'react'), + [client, domain, openfeature], ); return {children}; diff --git a/packages/server/package.json b/packages/server/package.json index ce2305a888..47c6ba72dd 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -7,10 +7,18 @@ "dist/" ], "exports": { - "types": "./dist/types.d.ts", - "import": "./dist/esm/index.js", - "require": "./dist/cjs/index.js", - "default": "./dist/cjs/index.js" + ".": { + "types": "./dist/types.d.ts", + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.js", + "default": "./dist/cjs/index.js" + }, + "./isolated": { + "types": "./dist/isolated.d.ts", + "import": "./dist/esm/isolated.js", + "require": "./dist/cjs/isolated.js", + "default": "./dist/cjs/isolated.js" + } }, "types": "./dist/types.d.ts", "scripts": { @@ -19,9 +27,12 @@ "lint:fix": "eslint ./ --fix", "clean": "shx rm -rf ./dist", "build:esm": "esbuild src/index.ts --bundle --external:@openfeature/core --sourcemap --target=es2015 --platform=node --format=esm --outfile=./dist/esm/index.js --analyze", + "build:esm-isolated": "esbuild src/isolated.ts --bundle --external:@openfeature/core --sourcemap --target=es2015 --platform=node --format=esm --outfile=./dist/esm/isolated.js --analyze", "build:cjs": "esbuild src/index.ts --bundle --external:@openfeature/core --sourcemap --target=es2015 --platform=node --format=cjs --outfile=./dist/cjs/index.js --analyze", + "build:cjs-isolated": "esbuild src/isolated.ts --bundle --external:@openfeature/core --sourcemap --target=es2015 --platform=node --format=cjs --outfile=./dist/cjs/isolated.js --analyze", "build:rollup-types": "rollup -c ../../rollup.config.mjs", - "build": "npm run clean && npm run build:esm && npm run build:cjs && npm run build:rollup-types", + "build:rollup-types-isolated": "rollup -c ./rollup.isolated.config.mjs", + "build": "npm run clean && npm run build:esm && npm run build:esm-isolated && npm run build:cjs && npm run build:cjs-isolated && npm run build:rollup-types && npm run build:rollup-types-isolated", "postbuild": "shx cp ./../../package.esm.json ./dist/esm/package.json", "current-version": "echo $npm_package_version", "prepack": "shx cp ./../../LICENSE ./LICENSE", diff --git a/packages/server/rollup.isolated.config.mjs b/packages/server/rollup.isolated.config.mjs new file mode 100644 index 0000000000..f965aa0326 --- /dev/null +++ b/packages/server/rollup.isolated.config.mjs @@ -0,0 +1,20 @@ +// This config rolls up all the types for the isolated module to a single declaration (d.ts) file. + +import dts from 'rollup-plugin-dts'; + +export default { + input: './src/isolated.ts', + output: { + file: './dist/isolated.d.ts', + format: 'es', + }, + // function indicating which deps should be considered external: external deps will NOT have their types bundled + external: (id) => { + // bundle everything except peer deps (@openfeature/*, @nest/*, react, rxjs) + return id.startsWith('@openfeature') || id.startsWith('@nest') || id === 'rxjs' || id === 'react'; + }, + plugins: [ + // use the rollup override tsconfig (applies equivalent in each sub-packages as well) + dts({ tsconfig: './tsconfig.rollup.json', respectExternal: true }), + ], +}; diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 249135cbf8..3091de2002 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -2,6 +2,7 @@ export * from './client'; export * from './provider'; export * from './evaluation'; export * from './open-feature'; +export * from './open-feature-base'; export * from './transaction-context'; export * from './events'; export * from './hooks'; diff --git a/packages/server/src/isolated.ts b/packages/server/src/isolated.ts new file mode 100644 index 0000000000..c4fd132ebf --- /dev/null +++ b/packages/server/src/isolated.ts @@ -0,0 +1,57 @@ +/** + * @module @openfeature/server-sdk/isolated + * Provides non-singleton OpenFeature API instances for testing and multi-tenant scenarios. + * WARNING: This module provides non-singleton instances that do NOT share state + * with the global OpenFeature singleton. Only use this when isolation is explicitly required. + * @example + * ```typescript + * import { createIsolatedOpenFeatureAPI } from '@openfeature/server-sdk/isolated'; + * + * const MyOpenFeature = createIsolatedOpenFeatureAPI(); + * MyOpenFeature.setProvider(myProvider); + * const client = MyOpenFeature.getClient(); + * ``` + */ + +import { OpenFeatureAPIBase } from './open-feature-base'; + +/** + * An isolated (non-singleton) OpenFeature API instance. + * This class is NOT exported from the main entry point. + * @internal + */ +class OpenFeatureIsolatedAPIImpl extends OpenFeatureAPIBase { + constructor() { + super(); + } +} + +/** + * Creates a new isolated OpenFeature API instance. + * Each instance has its own providers, context, hooks, event handlers, and transaction context propagator. + * State is NOT shared with the global singleton or other isolated instances. + * @returns {OpenFeatureIsolatedAPI} A new isolated OpenFeature API instance + * @example + * ```typescript + * import { createIsolatedOpenFeatureAPI } from '@openfeature/server-sdk/isolated'; + * + * // Create an isolated instance for testing + * const TestOpenFeature = createIsolatedOpenFeatureAPI(); + * + * // Configure it independently of the global singleton + * TestOpenFeature.setProvider(mockProvider); + * TestOpenFeature.setContext({ environment: 'test' }); + * + * // Get a client from the isolated instance + * const client = TestOpenFeature.getClient(); + * ``` + */ +export function createIsolatedOpenFeatureAPI(): OpenFeatureIsolatedAPI { + return new OpenFeatureIsolatedAPIImpl(); +} + +/** + * Type alias for an isolated OpenFeature API instance. + * This is the same interface as the base OpenFeature API. + */ +export type OpenFeatureIsolatedAPI = OpenFeatureAPIBase; diff --git a/packages/server/src/open-feature-base.ts b/packages/server/src/open-feature-base.ts new file mode 100644 index 0000000000..8e0eb7bd94 --- /dev/null +++ b/packages/server/src/open-feature-base.ts @@ -0,0 +1,243 @@ +import type { EvaluationContext, ManageContext, ServerProviderStatus } 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 } from './events'; +import type { Hook } from './hooks'; +import type { Provider } from './provider'; +import { NOOP_PROVIDER, ProviderStatus } from './provider'; +import type { + ManageTransactionContextPropagator, + TransactionContext, + TransactionContextPropagator, +} from './transaction-context'; +import { NOOP_TRANSACTION_CONTEXT_PROPAGATOR } from './transaction-context'; + +/** + * The base class for the OpenFeature Server SDK API. + * This class contains all the core functionality for managing providers, context, hooks, and events. + * It is extended by both the singleton OpenFeatureAPI and isolated instances. + */ +export class OpenFeatureAPIBase + extends OpenFeatureCommonAPI + implements + ManageContext, + ManageTransactionContextPropagator> +{ + protected _statusEnumType: typeof ProviderStatus = ProviderStatus; + protected _apiEmitter = new OpenFeatureEventEmitter(); + protected _defaultProvider: ProviderWrapper = new ProviderWrapper( + NOOP_PROVIDER, + ProviderStatus.NOT_READY, + this._statusEnumType, + ); + protected _domainScopedProviders: Map> = new Map(); + protected _createEventEmitter = () => new OpenFeatureEventEmitter(); + + private _transactionContextPropagator: TransactionContextPropagator = NOOP_TRANSACTION_CONTEXT_PROPAGATOR; + + protected constructor() { + super('server'); + } + + 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 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; + async setProviderAndWait(domainOrProvider?: string | Provider, providerOrUndefined?: Provider): Promise { + const domain = stringOrUndefined(domainOrProvider); + const provider = domain + ? objectOrUndefined(providerOrUndefined) + : objectOrUndefined(domainOrProvider); + + 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 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; + setProvider(clientOrProvider?: string | Provider, providerOrUndefined?: Provider): this { + const domain = stringOrUndefined(clientOrProvider); + const provider = domain + ? objectOrUndefined(providerOrUndefined) + : objectOrUndefined(clientOrProvider); + + 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); + } + + setContext(context: EvaluationContext): this { + this._context = context; + return this; + } + + getContext(): EvaluationContext { + return this._context; + } + + /** + * A factory function for creating new domainless OpenFeature clients. + * Clients can contain their own state (e.g. logger, hook, context). + * Multiple clients can be used to segment feature flag configuration. + * + * All domainless or unbound clients use the default provider set via {@link this.setProvider setProvider}. + * @param {EvaluationContext} context Evaluation context that should be set on the client to used during flag evaluations + * @returns {Client} OpenFeature Client + */ + getClient(context?: EvaluationContext): Client; + /** + * A factory function for creating new domain scoped OpenFeature clients. + * Clients can contain their own state (e.g. logger, hook, context). + * Multiple clients can be used to segment feature flag configuration. + * + * If there is already a provider bound to this domain via {@link this.setProvider setProvider}, this provider will be used. + * Otherwise, the default provider is used until a provider is assigned to that domain. + * @param {string} domain An identifier which logically binds clients with providers + * @param {EvaluationContext} context Evaluation context that should be set on the client to used during flag evaluations + * @returns {Client} OpenFeature Client + */ + getClient(domain: string, context?: EvaluationContext): Client; + /** + * A factory function for creating new domain scoped OpenFeature clients. + * Clients can contain their own state (e.g. logger, hook, context). + * Multiple clients can be used to segment feature flag configuration. + * + * If there is already a provider bound to this domain via {@link this.setProvider setProvider}, this provider will be used. + * Otherwise, the default provider is used until a provider is assigned to that domain. + * @param {string} domain An identifier which logically binds clients with providers + * @param {string} version The version of the client (only used for metadata) + * @param {EvaluationContext} context Evaluation context that should be set on the client to used during flag evaluations + * @returns {Client} OpenFeature Client + */ + getClient(domain: string, version: string, context?: EvaluationContext): Client; + getClient( + domainOrContext?: string | EvaluationContext, + versionOrContext?: string | EvaluationContext, + contextOrUndefined?: EvaluationContext, + ): Client { + const domain = stringOrUndefined(domainOrContext); + const version = stringOrUndefined(versionOrContext); + const context = + objectOrUndefined(domainOrContext) ?? + objectOrUndefined(versionOrContext) ?? + objectOrUndefined(contextOrUndefined); + + return new OpenFeatureClient( + () => this.getProviderForClient(domain), + () => this.getProviderStatus(domain), + () => this.buildAndCacheEventEmitterForClient(domain), + () => this.getContext(), + () => this.getHooks(), + () => this.getTransactionContext(), + () => this._logger, + { domain, version }, + context, + ); + } + + /** + * Clears all registered providers and resets the default provider. + * @returns {Promise} + */ + clearProviders(): Promise { + return super.clearProvidersAndSetDefault(NOOP_PROVIDER); + } + + setTransactionContextPropagator( + transactionContextPropagator: TransactionContextPropagator, + ): OpenFeatureCommonAPI { + const baseMessage = 'Invalid TransactionContextPropagator, will not be set: '; + if (typeof transactionContextPropagator?.getTransactionContext !== 'function') { + this._logger.error(`${baseMessage}: getTransactionContext is not a function.`); + } else if (typeof transactionContextPropagator?.setTransactionContext !== 'function') { + this._logger.error(`${baseMessage}: setTransactionContext is not a function.`); + } else { + this._transactionContextPropagator = transactionContextPropagator; + } + return this; + } + + setTransactionContext( + transactionContext: TransactionContext, + callback: (...args: TArgs) => R, + ...args: TArgs + ): void { + this._transactionContextPropagator.setTransactionContext(transactionContext, callback, ...args); + } + + getTransactionContext(): TransactionContext { + try { + return this._transactionContextPropagator.getTransactionContext(); + } catch (err: unknown) { + const error = err as Error | undefined; + this._logger.error(`Error getting transaction context: ${error?.message}, returning empty context.`); + this._logger.error(error?.stack); + return {}; + } + } +} diff --git a/packages/server/src/open-feature.ts b/packages/server/src/open-feature.ts index 1bc491937d..f7711bef6c 100644 --- a/packages/server/src/open-feature.ts +++ b/packages/server/src/open-feature.ts @@ -1,17 +1,4 @@ -import type { EvaluationContext, ManageContext, ServerProviderStatus } 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 } from './events'; -import type { Hook } from './hooks'; -import type { Provider } from './provider'; -import { NOOP_PROVIDER, ProviderStatus } from './provider'; -import type { - ManageTransactionContextPropagator, - TransactionContext, - TransactionContextPropagator, -} from './transaction-context'; -import { NOOP_TRANSACTION_CONTEXT_PROPAGATOR } from './transaction-context'; +import { OpenFeatureAPIBase } from './open-feature-base'; // use a symbol as a key for the global singleton const GLOBAL_OPENFEATURE_API_KEY = Symbol.for('@openfeature/js-sdk/api'); @@ -21,26 +8,16 @@ type OpenFeatureGlobal = { }; const _globalThis = globalThis as OpenFeatureGlobal; -export class OpenFeatureAPI - extends OpenFeatureCommonAPI - implements - ManageContext, - ManageTransactionContextPropagator> -{ - protected _statusEnumType: typeof ProviderStatus = ProviderStatus; - protected _apiEmitter = new OpenFeatureEventEmitter(); - protected _defaultProvider: ProviderWrapper = new ProviderWrapper( - NOOP_PROVIDER, - ProviderStatus.NOT_READY, - this._statusEnumType, - ); - protected _domainScopedProviders: Map> = new Map(); - protected _createEventEmitter = () => new OpenFeatureEventEmitter(); - - private _transactionContextPropagator: TransactionContextPropagator = NOOP_TRANSACTION_CONTEXT_PROPAGATOR; - +/** + * The OpenFeatureAPI is the entry point for the OpenFeature SDK. + * This is a singleton class that provides access to the global OpenFeature API instance. + * + * For isolated (non-singleton) instances, use the `createIsolatedOpenFeatureAPI` function + * from `@openfeature/server-sdk/isolated`. + */ +export class OpenFeatureAPI extends OpenFeatureAPIBase { private constructor() { - super('server'); + super(); } /** @@ -58,207 +35,6 @@ export class 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 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; - async setProviderAndWait(domainOrProvider?: string | Provider, providerOrUndefined?: Provider): Promise { - const domain = stringOrUndefined(domainOrProvider); - const provider = domain - ? objectOrUndefined(providerOrUndefined) - : objectOrUndefined(domainOrProvider); - - 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 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; - setProvider(clientOrProvider?: string | Provider, providerOrUndefined?: Provider): this { - const domain = stringOrUndefined(clientOrProvider); - const provider = domain - ? objectOrUndefined(providerOrUndefined) - : objectOrUndefined(clientOrProvider); - - 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); - } - - setContext(context: EvaluationContext): this { - this._context = context; - return this; - } - - getContext(): EvaluationContext { - return this._context; - } - - /** - * A factory function for creating new domainless OpenFeature clients. - * Clients can contain their own state (e.g. logger, hook, context). - * Multiple clients can be used to segment feature flag configuration. - * - * All domainless or unbound clients use the default provider set via {@link this.setProvider setProvider}. - * @param {EvaluationContext} context Evaluation context that should be set on the client to used during flag evaluations - * @returns {Client} OpenFeature Client - */ - getClient(context?: EvaluationContext): Client; - /** - * A factory function for creating new domain scoped OpenFeature clients. - * Clients can contain their own state (e.g. logger, hook, context). - * Multiple clients can be used to segment feature flag configuration. - * - * If there is already a provider bound to this domain via {@link this.setProvider setProvider}, this provider will be used. - * Otherwise, the default provider is used until a provider is assigned to that domain. - * @param {string} domain An identifier which logically binds clients with providers - * @param {EvaluationContext} context Evaluation context that should be set on the client to used during flag evaluations - * @returns {Client} OpenFeature Client - */ - getClient(domain: string, context?: EvaluationContext): Client; - /** - * A factory function for creating new domain scoped OpenFeature clients. - * Clients can contain their own state (e.g. logger, hook, context). - * Multiple clients can be used to segment feature flag configuration. - * - * If there is already a provider bound to this domain via {@link this.setProvider setProvider}, this provider will be used. - * Otherwise, the default provider is used until a provider is assigned to that domain. - * @param {string} domain An identifier which logically binds clients with providers - * @param {string} version The version of the client (only used for metadata) - * @param {EvaluationContext} context Evaluation context that should be set on the client to used during flag evaluations - * @returns {Client} OpenFeature Client - */ - getClient(domain: string, version: string, context?: EvaluationContext): Client; - getClient( - domainOrContext?: string | EvaluationContext, - versionOrContext?: string | EvaluationContext, - contextOrUndefined?: EvaluationContext, - ): Client { - const domain = stringOrUndefined(domainOrContext); - const version = stringOrUndefined(versionOrContext); - const context = - objectOrUndefined(domainOrContext) ?? - objectOrUndefined(versionOrContext) ?? - objectOrUndefined(contextOrUndefined); - - return new OpenFeatureClient( - () => this.getProviderForClient(domain), - () => this.getProviderStatus(domain), - () => this.buildAndCacheEventEmitterForClient(domain), - () => this.getContext(), - () => this.getHooks(), - () => this.getTransactionContext(), - () => this._logger, - { domain, version }, - context, - ); - } - - /** - * Clears all registered providers and resets the default provider. - * @returns {Promise} - */ - clearProviders(): Promise { - return super.clearProvidersAndSetDefault(NOOP_PROVIDER); - } - - setTransactionContextPropagator( - transactionContextPropagator: TransactionContextPropagator, - ): OpenFeatureCommonAPI { - const baseMessage = 'Invalid TransactionContextPropagator, will not be set: '; - if (typeof transactionContextPropagator?.getTransactionContext !== 'function') { - this._logger.error(`${baseMessage}: getTransactionContext is not a function.`); - } else if (typeof transactionContextPropagator?.setTransactionContext !== 'function') { - this._logger.error(`${baseMessage}: setTransactionContext is not a function.`); - } else { - this._transactionContextPropagator = transactionContextPropagator; - } - return this; - } - - setTransactionContext( - transactionContext: TransactionContext, - callback: (...args: TArgs) => R, - ...args: TArgs - ): void { - this._transactionContextPropagator.setTransactionContext(transactionContext, callback, ...args); - } - - getTransactionContext(): TransactionContext { - try { - return this._transactionContextPropagator.getTransactionContext(); - } catch (err: unknown) { - const error = err as Error | undefined; - this._logger.error(`Error getting transaction context: ${error?.message}, returning empty context.`); - this._logger.error(error?.stack); - return {}; - } - } } /** diff --git a/packages/server/test/isolated.spec.ts b/packages/server/test/isolated.spec.ts new file mode 100644 index 0000000000..6fc4ca279f --- /dev/null +++ b/packages/server/test/isolated.spec.ts @@ -0,0 +1,204 @@ +import type { Provider, ResolutionDetails } from '../src'; +import { OpenFeature, ProviderEvents } from '../src'; +import { createIsolatedOpenFeatureAPI } from '../src/isolated'; + +const MOCK_PROVIDER: Provider = { + metadata: { + name: 'mock-provider', + }, + resolveBooleanEvaluation: (): Promise> => + Promise.resolve({ value: true, reason: 'STATIC' }), + resolveStringEvaluation: (): Promise> => + Promise.resolve({ value: 'test', reason: 'STATIC' }), + resolveNumberEvaluation: (): Promise> => Promise.resolve({ value: 1, reason: 'STATIC' }), + resolveObjectEvaluation: (): Promise> => + Promise.resolve({ value: {} as T, reason: 'STATIC' }), +}; + +const MOCK_PROVIDER_2: Provider = { + metadata: { + name: 'mock-provider-2', + }, + resolveBooleanEvaluation: (): Promise> => + Promise.resolve({ value: false, reason: 'STATIC' }), + resolveStringEvaluation: (): Promise> => + Promise.resolve({ value: 'test2', reason: 'STATIC' }), + resolveNumberEvaluation: (): Promise> => Promise.resolve({ value: 2, reason: 'STATIC' }), + resolveObjectEvaluation: (): Promise> => + Promise.resolve({ value: {} as T, reason: 'STATIC' }), +}; + +describe('Isolated OpenFeature API (Server)', () => { + afterEach(async () => { + await OpenFeature.clearProviders(); + OpenFeature.setContext({}); + OpenFeature.clearHooks(); + }); + + describe('createIsolatedOpenFeatureAPI', () => { + it('should create a new instance each time', () => { + const instance1 = createIsolatedOpenFeatureAPI(); + const instance2 = createIsolatedOpenFeatureAPI(); + expect(instance1).not.toBe(instance2); + }); + + it('should create an instance different from the singleton', () => { + const isolated = createIsolatedOpenFeatureAPI(); + expect(isolated).not.toBe(OpenFeature); + }); + }); + + describe('provider isolation', () => { + it('should not share providers with singleton', async () => { + const isolated = createIsolatedOpenFeatureAPI(); + + isolated.setProvider(MOCK_PROVIDER); + OpenFeature.setProvider(MOCK_PROVIDER_2); + + expect(isolated.getProvider().metadata.name).toBe('mock-provider'); + expect(OpenFeature.getProvider().metadata.name).toBe('mock-provider-2'); + }); + + it('should not share domain-scoped providers with singleton', async () => { + const isolated = createIsolatedOpenFeatureAPI(); + + isolated.setProvider('domain-a', MOCK_PROVIDER); + OpenFeature.setProvider('domain-a', MOCK_PROVIDER_2); + + expect(isolated.getProvider('domain-a').metadata.name).toBe('mock-provider'); + expect(OpenFeature.getProvider('domain-a').metadata.name).toBe('mock-provider-2'); + }); + + it('should not share providers between isolated instances', async () => { + const instance1 = createIsolatedOpenFeatureAPI(); + const instance2 = createIsolatedOpenFeatureAPI(); + + instance1.setProvider(MOCK_PROVIDER); + instance2.setProvider(MOCK_PROVIDER_2); + + expect(instance1.getProvider().metadata.name).toBe('mock-provider'); + expect(instance2.getProvider().metadata.name).toBe('mock-provider-2'); + }); + }); + + describe('context isolation', () => { + it('should not share context with singleton', () => { + const isolated = createIsolatedOpenFeatureAPI(); + + isolated.setContext({ isolated: true, user: 'isolated-user' }); + OpenFeature.setContext({ singleton: true, user: 'singleton-user' }); + + expect(isolated.getContext()).toEqual({ isolated: true, user: 'isolated-user' }); + expect(OpenFeature.getContext()).toEqual({ singleton: true, user: 'singleton-user' }); + }); + + it('should not share context between isolated instances', () => { + const instance1 = createIsolatedOpenFeatureAPI(); + const instance2 = createIsolatedOpenFeatureAPI(); + + instance1.setContext({ instance: 1 }); + instance2.setContext({ instance: 2 }); + + expect(instance1.getContext()).toEqual({ instance: 1 }); + expect(instance2.getContext()).toEqual({ instance: 2 }); + }); + }); + + describe('hooks isolation', () => { + it('should not share hooks with singleton', () => { + const isolated = createIsolatedOpenFeatureAPI(); + const mockHook = { before: jest.fn() }; + const singletonHook = { before: jest.fn() }; + + isolated.addHooks(mockHook); + OpenFeature.addHooks(singletonHook); + + expect(isolated.getHooks()).toContain(mockHook); + expect(isolated.getHooks()).not.toContain(singletonHook); + expect(OpenFeature.getHooks()).toContain(singletonHook); + expect(OpenFeature.getHooks()).not.toContain(mockHook); + }); + + it('should not share hooks between isolated instances', () => { + const instance1 = createIsolatedOpenFeatureAPI(); + const instance2 = createIsolatedOpenFeatureAPI(); + const hook1 = { before: jest.fn() }; + const hook2 = { before: jest.fn() }; + + instance1.addHooks(hook1); + instance2.addHooks(hook2); + + expect(instance1.getHooks()).toContain(hook1); + expect(instance1.getHooks()).not.toContain(hook2); + expect(instance2.getHooks()).toContain(hook2); + expect(instance2.getHooks()).not.toContain(hook1); + }); + }); + + describe('client isolation', () => { + it('should create clients that use the isolated provider', async () => { + const isolated = createIsolatedOpenFeatureAPI(); + + await isolated.setProviderAndWait(MOCK_PROVIDER); + await OpenFeature.setProviderAndWait(MOCK_PROVIDER_2); + + const isolatedClient = isolated.getClient(); + const singletonClient = OpenFeature.getClient(); + + expect(await isolatedClient.getBooleanValue('test-flag', false)).toBe(true); + expect(await singletonClient.getBooleanValue('test-flag', true)).toBe(false); + }); + + it('should create clients that use isolated context', () => { + const isolated = createIsolatedOpenFeatureAPI(); + + isolated.setContext({ user: 'isolated-user' }); + OpenFeature.setContext({ user: 'singleton-user' }); + + expect(isolated.getContext()).toEqual({ user: 'isolated-user' }); + expect(OpenFeature.getContext()).toEqual({ user: 'singleton-user' }); + }); + }); + + describe('event handler isolation', () => { + it('should not share event handlers with singleton', () => { + const isolated = createIsolatedOpenFeatureAPI(); + + // Add handlers to each instance + isolated.addHandler(ProviderEvents.Ready, jest.fn()); + OpenFeature.addHandler(ProviderEvents.Ready, jest.fn()); + + const isolatedHandlerCount = isolated.getHandlers(ProviderEvents.Ready).length; + const singletonHandlerCount = OpenFeature.getHandlers(ProviderEvents.Ready).length; + + // Each instance should only have its own handler + // The key is that adding to one doesn't affect the other's count + isolated.addHandler(ProviderEvents.Ready, jest.fn()); + + // Adding another handler to isolated should not affect singleton + expect(isolated.getHandlers(ProviderEvents.Ready).length).toBe(isolatedHandlerCount + 1); + expect(OpenFeature.getHandlers(ProviderEvents.Ready).length).toBe(singletonHandlerCount); + }); + }); + + describe('transaction context isolation', () => { + it('should not share transaction context propagator with singleton', () => { + const isolated = createIsolatedOpenFeatureAPI(); + + const isolatedPropagator = { + getTransactionContext: jest.fn(() => ({ isolated: true })), + setTransactionContext: jest.fn(), + }; + const singletonPropagator = { + getTransactionContext: jest.fn(() => ({ singleton: true })), + setTransactionContext: jest.fn(), + }; + + isolated.setTransactionContextPropagator(isolatedPropagator); + OpenFeature.setTransactionContextPropagator(singletonPropagator); + + expect(isolated.getTransactionContext()).toEqual({ isolated: true }); + expect(OpenFeature.getTransactionContext()).toEqual({ singleton: true }); + }); + }); +}); diff --git a/packages/web/package.json b/packages/web/package.json index da0f81d4c7..9303ead587 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -9,10 +9,18 @@ "dist/" ], "exports": { - "types": "./dist/types.d.ts", - "import": "./dist/esm/index.js", - "require": "./dist/cjs/index.js", - "default": "./dist/cjs/index.js" + ".": { + "types": "./dist/types.d.ts", + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.js", + "default": "./dist/cjs/index.js" + }, + "./isolated": { + "types": "./dist/isolated.d.ts", + "import": "./dist/esm/isolated.js", + "require": "./dist/cjs/isolated.js", + "default": "./dist/cjs/isolated.js" + } }, "types": "./dist/types.d.ts", "scripts": { @@ -21,11 +29,14 @@ "lint:fix": "eslint ./ --fix", "clean": "shx rm -rf ./dist", "build:web-esm": "esbuild src/index.ts --bundle --external:@openfeature/core --sourcemap --target=es2015 --platform=browser --format=esm --outfile=./dist/esm/index.js --analyze", + "build:web-esm-isolated": "esbuild src/isolated.ts --bundle --external:@openfeature/core --sourcemap --target=es2015 --platform=browser --format=esm --outfile=./dist/esm/isolated.js --analyze", "build:web-cjs": "esbuild src/index.ts --bundle --external:@openfeature/core --sourcemap --target=es2015 --platform=browser --format=cjs --outfile=./dist/cjs/index.js --analyze", + "build:web-cjs-isolated": "esbuild src/isolated.ts --bundle --external:@openfeature/core --sourcemap --target=es2015 --platform=browser --format=cjs --outfile=./dist/cjs/isolated.js --analyze", "build:web-global": "esbuild src/index.ts --bundle --sourcemap --target=es2015 --platform=browser --format=iife --outfile=./dist/global/index.js --global-name=OpenFeature --analyze", "build:web-global:min": "esbuild src/index.ts --bundle --sourcemap --target=es2015 --platform=browser --format=iife --outfile=./dist/global/index.min.js --global-name=OpenFeature --minify --analyze", "build:rollup-types": "rollup -c ../../rollup.config.mjs", - "build": "npm run clean && npm run build:web-esm && npm run build:web-cjs && npm run build:web-global && npm run build:web-global:min && npm run build:rollup-types", + "build:rollup-types-isolated": "rollup -c ./rollup.isolated.config.mjs", + "build": "npm run clean && npm run build:web-esm && npm run build:web-esm-isolated && npm run build:web-cjs && npm run build:web-cjs-isolated && npm run build:web-global && npm run build:web-global:min && npm run build:rollup-types && npm run build:rollup-types-isolated", "postbuild": "shx cp ./../../package.esm.json ./dist/esm/package.json", "current-version": "echo $npm_package_version", "prepack": "shx cp ./../../LICENSE ./LICENSE", diff --git a/packages/web/rollup.isolated.config.mjs b/packages/web/rollup.isolated.config.mjs new file mode 100644 index 0000000000..f965aa0326 --- /dev/null +++ b/packages/web/rollup.isolated.config.mjs @@ -0,0 +1,20 @@ +// This config rolls up all the types for the isolated module to a single declaration (d.ts) file. + +import dts from 'rollup-plugin-dts'; + +export default { + input: './src/isolated.ts', + output: { + file: './dist/isolated.d.ts', + format: 'es', + }, + // function indicating which deps should be considered external: external deps will NOT have their types bundled + external: (id) => { + // bundle everything except peer deps (@openfeature/*, @nest/*, react, rxjs) + return id.startsWith('@openfeature') || id.startsWith('@nest') || id === 'rxjs' || id === 'react'; + }, + plugins: [ + // use the rollup override tsconfig (applies equivalent in each sub-packages as well) + dts({ tsconfig: './tsconfig.rollup.json', respectExternal: true }), + ], +}; diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts index fa481b7336..2ceb6b5e4f 100644 --- a/packages/web/src/index.ts +++ b/packages/web/src/index.ts @@ -2,6 +2,7 @@ export * from './client'; export * from './provider'; export * from './evaluation'; export * from './open-feature'; +export * from './open-feature-base'; export * from './events'; export * from './hooks'; export * from './tracking'; diff --git a/packages/web/src/isolated.ts b/packages/web/src/isolated.ts new file mode 100644 index 0000000000..72b3bd3607 --- /dev/null +++ b/packages/web/src/isolated.ts @@ -0,0 +1,58 @@ +/** + * @module @openfeature/web-sdk/isolated + * Provides non-singleton OpenFeature API instances for micro-frontend architectures. + * WARNING: This module provides non-singleton instances that do NOT share state + * with the global OpenFeature singleton. Only use this in micro-frontend + * architectures where isolation is explicitly required. + * @example + * ```typescript + * import { createIsolatedOpenFeatureAPI } from '@openfeature/web-sdk/isolated'; + * + * const MyOpenFeature = createIsolatedOpenFeatureAPI(); + * MyOpenFeature.setProvider(myProvider); + * const client = MyOpenFeature.getClient(); + * ``` + */ + +import { OpenFeatureAPIBase } from './open-feature-base'; + +/** + * An isolated (non-singleton) OpenFeature API instance. + * This class is NOT exported from the main entry point. + * @internal + */ +class OpenFeatureIsolatedAPIImpl extends OpenFeatureAPIBase { + constructor() { + super(); + } +} + +/** + * Creates a new isolated OpenFeature API instance. + * Each instance has its own providers, context, hooks, and event handlers. + * State is NOT shared with the global singleton or other isolated instances. + * @returns {OpenFeatureIsolatedAPI} A new isolated OpenFeature API instance + * @example + * ```typescript + * import { createIsolatedOpenFeatureAPI } from '@openfeature/web-sdk/isolated'; + * + * // Create an isolated instance for this micro-frontend + * const MyOpenFeature = createIsolatedOpenFeatureAPI(); + * + * // Configure it independently of the global singleton + * MyOpenFeature.setProvider(myProvider); + * await MyOpenFeature.setContext({ user: 'micro-frontend-user' }); + * + * // Get a client from the isolated instance + * const client = MyOpenFeature.getClient(); + * ``` + */ +export function createIsolatedOpenFeatureAPI(): OpenFeatureIsolatedAPI { + return new OpenFeatureIsolatedAPIImpl(); +} + +/** + * Type alias for an isolated OpenFeature API instance. + * This is the same interface as the base OpenFeature API. + */ +export type OpenFeatureIsolatedAPI = OpenFeatureAPIBase; diff --git a/packages/web/src/open-feature-base.ts b/packages/web/src/open-feature-base.ts new file mode 100644 index 0000000000..95905a46d0 --- /dev/null +++ b/packages/web/src/open-feature-base.ts @@ -0,0 +1,403 @@ +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'; + +type DomainRecord = { + domain?: string; + wrapper: ProviderWrapper; +}; + +/** + * The base class for the OpenFeature Web SDK API. + * This class contains all the core functionality for managing providers, context, hooks, and events. + * It is extended by both the singleton OpenFeatureAPI and isolated instances. + */ +export class OpenFeatureAPIBase + 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(); + + protected constructor() { + super('client'); + } + + 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 (maybePromise && 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 }); + } + } + } +} diff --git a/packages/web/src/open-feature.ts b/packages/web/src/open-feature.ts index 131f8c2731..9aeac2831f 100644 --- a/packages/web/src/open-feature.ts +++ b/packages/web/src/open-feature.ts @@ -1,11 +1,4 @@ -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'; +import { OpenFeatureAPIBase } from './open-feature-base'; // use a symbol as a key for the global singleton const GLOBAL_OPENFEATURE_API_KEY = Symbol.for('@openfeature/web-sdk/api'); @@ -13,29 +6,19 @@ 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(); - +/** + * The OpenFeatureAPI is the entry point for the OpenFeature SDK. + * This is a singleton class that provides access to the global OpenFeature API instance. + * + * For isolated (non-singleton) instances, use the `createIsolatedOpenFeatureAPI` function + * from `@openfeature/web-sdk/isolated`. + */ +export class OpenFeatureAPI extends OpenFeatureAPIBase { private constructor() { - super('client'); + super(); } /** @@ -53,372 +36,6 @@ export class 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 (maybePromise && 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 }); - } - } - } } /** diff --git a/packages/web/test/isolated.spec.ts b/packages/web/test/isolated.spec.ts new file mode 100644 index 0000000000..3815b09102 --- /dev/null +++ b/packages/web/test/isolated.spec.ts @@ -0,0 +1,192 @@ +import type { Provider, ResolutionDetails } from '../src'; +import { OpenFeature, ProviderEvents } from '../src'; +import { createIsolatedOpenFeatureAPI } from '../src/isolated'; + +const MOCK_PROVIDER: Provider = { + metadata: { + name: 'mock-provider', + }, + resolveBooleanEvaluation: (): ResolutionDetails => ({ value: true, reason: 'STATIC' }), + resolveStringEvaluation: (): ResolutionDetails => ({ value: 'test', reason: 'STATIC' }), + resolveNumberEvaluation: (): ResolutionDetails => ({ value: 1, reason: 'STATIC' }), + resolveObjectEvaluation: (): ResolutionDetails => ({ value: {} as T, reason: 'STATIC' }), +}; + +const MOCK_PROVIDER_2: Provider = { + metadata: { + name: 'mock-provider-2', + }, + resolveBooleanEvaluation: (): ResolutionDetails => ({ value: false, reason: 'STATIC' }), + resolveStringEvaluation: (): ResolutionDetails => ({ value: 'test2', reason: 'STATIC' }), + resolveNumberEvaluation: (): ResolutionDetails => ({ value: 2, reason: 'STATIC' }), + resolveObjectEvaluation: (): ResolutionDetails => ({ value: {} as T, reason: 'STATIC' }), +}; + +describe('Isolated OpenFeature API (Web)', () => { + afterEach(async () => { + await OpenFeature.clearProviders(); + await OpenFeature.clearContexts(); + OpenFeature.clearHooks(); + }); + + describe('createIsolatedOpenFeatureAPI', () => { + it('should create a new instance each time', () => { + const instance1 = createIsolatedOpenFeatureAPI(); + const instance2 = createIsolatedOpenFeatureAPI(); + expect(instance1).not.toBe(instance2); + }); + + it('should create an instance different from the singleton', () => { + const isolated = createIsolatedOpenFeatureAPI(); + expect(isolated).not.toBe(OpenFeature); + }); + }); + + describe('provider isolation', () => { + it('should not share providers with singleton', async () => { + const isolated = createIsolatedOpenFeatureAPI(); + + isolated.setProvider(MOCK_PROVIDER); + OpenFeature.setProvider(MOCK_PROVIDER_2); + + expect(isolated.getProvider().metadata.name).toBe('mock-provider'); + expect(OpenFeature.getProvider().metadata.name).toBe('mock-provider-2'); + }); + + it('should not share domain-scoped providers with singleton', async () => { + const isolated = createIsolatedOpenFeatureAPI(); + + isolated.setProvider('domain-a', MOCK_PROVIDER); + OpenFeature.setProvider('domain-a', MOCK_PROVIDER_2); + + expect(isolated.getProvider('domain-a').metadata.name).toBe('mock-provider'); + expect(OpenFeature.getProvider('domain-a').metadata.name).toBe('mock-provider-2'); + }); + + it('should not share providers between isolated instances', async () => { + const instance1 = createIsolatedOpenFeatureAPI(); + const instance2 = createIsolatedOpenFeatureAPI(); + + instance1.setProvider(MOCK_PROVIDER); + instance2.setProvider(MOCK_PROVIDER_2); + + expect(instance1.getProvider().metadata.name).toBe('mock-provider'); + expect(instance2.getProvider().metadata.name).toBe('mock-provider-2'); + }); + }); + + describe('context isolation', () => { + it('should not share context with singleton', async () => { + const isolated = createIsolatedOpenFeatureAPI(); + + await isolated.setContext({ isolated: true, user: 'isolated-user' }); + await OpenFeature.setContext({ singleton: true, user: 'singleton-user' }); + + expect(isolated.getContext()).toEqual({ isolated: true, user: 'isolated-user' }); + expect(OpenFeature.getContext()).toEqual({ singleton: true, user: 'singleton-user' }); + }); + + it('should not share domain-scoped context with singleton', async () => { + const isolated = createIsolatedOpenFeatureAPI(); + + await isolated.setContext('domain-a', { source: 'isolated' }); + await OpenFeature.setContext('domain-a', { source: 'singleton' }); + + expect(isolated.getContext('domain-a')).toEqual({ source: 'isolated' }); + expect(OpenFeature.getContext('domain-a')).toEqual({ source: 'singleton' }); + }); + + it('should not share context between isolated instances', async () => { + const instance1 = createIsolatedOpenFeatureAPI(); + const instance2 = createIsolatedOpenFeatureAPI(); + + await instance1.setContext({ instance: 1 }); + await instance2.setContext({ instance: 2 }); + + expect(instance1.getContext()).toEqual({ instance: 1 }); + expect(instance2.getContext()).toEqual({ instance: 2 }); + }); + }); + + describe('hooks isolation', () => { + it('should not share hooks with singleton', () => { + const isolated = createIsolatedOpenFeatureAPI(); + const mockHook = { before: jest.fn() }; + const singletonHook = { before: jest.fn() }; + + isolated.addHooks(mockHook); + OpenFeature.addHooks(singletonHook); + + expect(isolated.getHooks()).toContain(mockHook); + expect(isolated.getHooks()).not.toContain(singletonHook); + expect(OpenFeature.getHooks()).toContain(singletonHook); + expect(OpenFeature.getHooks()).not.toContain(mockHook); + }); + + it('should not share hooks between isolated instances', () => { + const instance1 = createIsolatedOpenFeatureAPI(); + const instance2 = createIsolatedOpenFeatureAPI(); + const hook1 = { before: jest.fn() }; + const hook2 = { before: jest.fn() }; + + instance1.addHooks(hook1); + instance2.addHooks(hook2); + + expect(instance1.getHooks()).toContain(hook1); + expect(instance1.getHooks()).not.toContain(hook2); + expect(instance2.getHooks()).toContain(hook2); + expect(instance2.getHooks()).not.toContain(hook1); + }); + }); + + describe('client isolation', () => { + it('should create clients that use the isolated provider', async () => { + const isolated = createIsolatedOpenFeatureAPI(); + + await isolated.setProviderAndWait(MOCK_PROVIDER); + await OpenFeature.setProviderAndWait(MOCK_PROVIDER_2); + + const isolatedClient = isolated.getClient(); + const singletonClient = OpenFeature.getClient(); + + expect(isolatedClient.getBooleanValue('test-flag', false)).toBe(true); + expect(singletonClient.getBooleanValue('test-flag', true)).toBe(false); + }); + + it('should create clients that use isolated context', async () => { + const isolated = createIsolatedOpenFeatureAPI(); + + await isolated.setContext({ user: 'isolated-user' }); + await OpenFeature.setContext({ user: 'singleton-user' }); + + // Create clients to verify they can be created from both instances + const _isolatedClient = isolated.getClient(); + const _singletonClient = OpenFeature.getClient(); + + // Verify the context isolation + expect(isolated.getContext()).toEqual({ user: 'isolated-user' }); + expect(OpenFeature.getContext()).toEqual({ user: 'singleton-user' }); + }); + }); + + describe('event handler isolation', () => { + it('should not share event handlers with singleton', () => { + const isolated = createIsolatedOpenFeatureAPI(); + + // Add handlers to each instance + isolated.addHandler(ProviderEvents.Ready, jest.fn()); + OpenFeature.addHandler(ProviderEvents.Ready, jest.fn()); + + const isolatedHandlerCount = isolated.getHandlers(ProviderEvents.Ready).length; + const singletonHandlerCount = OpenFeature.getHandlers(ProviderEvents.Ready).length; + + // Each instance should only have its own handler (plus any that fire immediately) + // The key is that adding to one doesn't affect the other's count + isolated.addHandler(ProviderEvents.Ready, jest.fn()); + + // Adding another handler to isolated should not affect singleton + expect(isolated.getHandlers(ProviderEvents.Ready).length).toBe(isolatedHandlerCount + 1); + expect(OpenFeature.getHandlers(ProviderEvents.Ready).length).toBe(singletonHandlerCount); + }); + }); +});