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);
+ });
+ });
+});