diff --git a/packages/web/src/open-feature.ts b/packages/web/src/open-feature.ts index be6c7f845..a70bcd47f 100644 --- a/packages/web/src/open-feature.ts +++ b/packages/web/src/open-feature.ts @@ -28,6 +28,7 @@ type DomainRecord = { }; const _globalThis = globalThis as OpenFeatureGlobal; +const _localThis = {} as OpenFeatureGlobal; export class OpenFeatureAPI extends OpenFeatureCommonAPI @@ -50,16 +51,19 @@ export class OpenFeatureAPI /** * Gets a singleton instance of the OpenFeature API. * @ignore + * @param {boolean} global Whether to get the global (window) singleton instance or a package-local singleton instance. * @returns {OpenFeatureAPI} OpenFeature API */ - static getInstance(): OpenFeatureAPI { - const globalApi = _globalThis[GLOBAL_OPENFEATURE_API_KEY]; + static getInstance(global = true): OpenFeatureAPI { + const store = global ? _globalThis : _localThis; + + const globalApi = store[GLOBAL_OPENFEATURE_API_KEY]; if (globalApi) { return globalApi; } const instance = new OpenFeatureAPI(); - _globalThis[GLOBAL_OPENFEATURE_API_KEY] = instance; + store[GLOBAL_OPENFEATURE_API_KEY] = instance; return instance; } @@ -430,8 +434,60 @@ export class OpenFeatureAPI } } +interface OpenFeatureAPIWithIsolated extends OpenFeatureAPI { + /** + * An package-local singleton instance of the OpenFeature API. + * + * By default, the OpenFeature API is exposed as a global singleton (stored on `window` in browsers) instance. + * While this can be very convenient as domains, providers, etc., are shared across an entire application, + * this can mean that in multi-frontend architectures (e.g. micro-frontends) different parts of an application + * can think they're loading different versions of OpenFeature, when they're actually all sharing the same instance. + * + * The `isolated` property provides access to a package-local singleton instance of the OpenFeature API, + * which is not shared globally, isolated from the global singleton. + * This allows different parts of an application to have their own isolated OpenFeature API instances, + * avoiding potential conflicts and ensuring they're using the expected version of the SDK. + * + * HOWEVER, the `isolated` instance is *isolated* to the package, and so will not share domains, providers, etc., with + * the global singleton instance. As it is still a singleton within the package though, it will share state with other + * uses of the `isolated` instance imported from the same package within the same micro-frontend. + * @example + * import { OpenFeature } from '@openfeature/web-sdk'; + * + * OpenFeature.setProvider(new MyGlobalProvider()); // Sets the provider for the default domain on the global instance + * OpenFeature.isolated.setProvider(new MyIsolatedProvider()); // Sets the provider for the default domain on the isolated instance + * + * const globalClient = OpenFeature.getClient(); // Uses MyGlobalProvider, the provider for the default domain on the global instance + * const isolatedClient = OpenFeature.isolated.getClient(); // Uses MyIsolatedProvider, the provider for the default domain on the isolated instance + * + * // In the same micro-frontend, in a different file ... + * import { OpenFeature } from '@openfeature/web-sdk'; + * + * const globalClient = OpenFeature.getClient(); // Uses MyGlobalProvider, the provider for the default domain on the global instance + * const isolatedClient = OpenFeature.isolated.getClient(); // Uses MyIsolatedProvider, the provider for the default domain on the isolated instance + * + * // In another micro-frontend, after the above has executed ... + * import { OpenFeature } from '@openfeature/web-sdk'; + * + * const globalClient = OpenFeature.getClient(); // Uses MyGlobalProvider, the provider for the default domain on the global instance + * const isolatedClient = OpenFeature.isolated.getClient(); // Returns the NOOP provider, as this is a different isolated instance + */ + readonly isolated: OpenFeatureAPI; +} + +const createOpenFeatureAPI = (): OpenFeatureAPIWithIsolated => { + const globalInstance = OpenFeatureAPI.getInstance(); + const localInstance = OpenFeatureAPI.getInstance(false); + + return Object.assign(globalInstance, { + get isolated() { + return localInstance; + }, + }); +}; + /** * A singleton instance of the OpenFeature API. - * @returns {OpenFeatureAPI} OpenFeature API + * @returns {OpenFeatureAPIWithIsolated} OpenFeature API */ -export const OpenFeature = OpenFeatureAPI.getInstance(); +export const OpenFeature = createOpenFeatureAPI(); diff --git a/packages/web/test/isolated.spec.ts b/packages/web/test/isolated.spec.ts new file mode 100644 index 000000000..fbd4ea44e --- /dev/null +++ b/packages/web/test/isolated.spec.ts @@ -0,0 +1,94 @@ +import type { JsonValue, OpenFeatureAPI, Provider, ProviderMetadata, ResolutionDetails } from '../src'; + +const GLOBAL_OPENFEATURE_API_KEY = Symbol.for('@openfeature/web-sdk/api'); + +class MockProvider implements Provider { + readonly metadata: ProviderMetadata; + + constructor(options?: { name?: string }) { + this.metadata = { name: options?.name ?? 'mock-provider' }; + } + + resolveBooleanEvaluation(): ResolutionDetails { + throw new Error('Not implemented'); + } + + resolveNumberEvaluation(): ResolutionDetails { + throw new Error('Not implemented'); + } + + resolveObjectEvaluation(): ResolutionDetails { + throw new Error('Not implemented'); + } + + resolveStringEvaluation(): ResolutionDetails { + throw new Error('Not implemented'); + } +} + +const _globalThis = globalThis as { + [GLOBAL_OPENFEATURE_API_KEY]?: OpenFeatureAPI; +}; + +describe('OpenFeature', () => { + beforeEach(() => { + Reflect.deleteProperty(_globalThis, GLOBAL_OPENFEATURE_API_KEY); + expect(_globalThis[GLOBAL_OPENFEATURE_API_KEY]).toBeUndefined(); + jest.resetModules(); + }); + + afterEach(async () => { + jest.clearAllMocks(); + }); + + it('should persist via globalThis (window in browsers)', async () => { + const firstInstance = (await import('../src')).OpenFeature; + + jest.resetModules(); + const secondInstance = (await import('../src')).OpenFeature; + + expect(firstInstance).toBe(secondInstance); + expect(_globalThis[GLOBAL_OPENFEATURE_API_KEY]).toBe(firstInstance); + }); + + describe('OpenFeature.isolated', () => { + it('should not be the same instance as the global singleton', async () => { + const { OpenFeature } = await import('../src'); + + expect(OpenFeature.isolated).not.toBe(OpenFeature); + }); + + it('should not share state between global and isolated instances', async () => { + const { OpenFeature, NOOP_PROVIDER } = await import('../src'); + const isolatedInstance = OpenFeature.isolated; + + const globalProvider = new MockProvider({ name: 'global-provider' }); + OpenFeature.setProvider(globalProvider); + + expect(OpenFeature.getProvider()).toBe(globalProvider); + expect(isolatedInstance.getProvider()).toBe(NOOP_PROVIDER); + + const isolatedProvider = new MockProvider({ name: 'isolated-provider' }); + isolatedInstance.setProvider(isolatedProvider); + + expect(OpenFeature.getProvider()).toBe(globalProvider); + expect(isolatedInstance.getProvider()).toBe(isolatedProvider); + }); + + it('should persist when imported multiple times', async () => { + const firstIsolatedInstance = (await import('../src')).OpenFeature.isolated; + const secondIsolatedInstance = (await import('../src')).OpenFeature.isolated; + + expect(firstIsolatedInstance).toBe(secondIsolatedInstance); + }); + + it('should not persist via globalThis (window in browsers)', async () => { + const firstIsolatedInstance = (await import('../src')).OpenFeature.isolated; + + jest.resetModules(); + const secondIsolatedInstance = (await import('../src')).OpenFeature.isolated; + + expect(firstIsolatedInstance).not.toBe(secondIsolatedInstance); + }); + }); +}); diff --git a/packages/web/test/tsconfig.json b/packages/web/test/tsconfig.json index f85162abb..146c1b0de 100644 --- a/packages/web/test/tsconfig.json +++ b/packages/web/test/tsconfig.json @@ -1,4 +1,7 @@ { "extends": "../tsconfig.json", "include": ["."], + "compilerOptions": { + "module": "es2020" + } }