Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 61 additions & 5 deletions packages/web/src/open-feature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type DomainRecord = {
};

const _globalThis = globalThis as OpenFeatureGlobal;
const _localThis = {} as OpenFeatureGlobal;

export class OpenFeatureAPI
extends OpenFeatureCommonAPI<ClientProviderStatus, Provider, Hook>
Expand All @@ -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;
}

Expand Down Expand Up @@ -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();
94 changes: 94 additions & 0 deletions packages/web/test/isolated.spec.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
throw new Error('Not implemented');
}

resolveNumberEvaluation(): ResolutionDetails<number> {
throw new Error('Not implemented');
}

resolveObjectEvaluation<T extends JsonValue>(): ResolutionDetails<T> {
throw new Error('Not implemented');
}

resolveStringEvaluation(): ResolutionDetails<string> {
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);
});
});
});
3 changes: 3 additions & 0 deletions packages/web/test/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
{
"extends": "../tsconfig.json",
"include": ["."],
"compilerOptions": {
"module": "es2020"
}
}