Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,9 @@ export class MultiProvider implements Provider {
Object.freeze(this.providerEntriesByName);
}

async initialize(context?: EvaluationContext): Promise<void> {
async initialize(context?: EvaluationContext, domain?: string): Promise<void> {
const result = await Promise.allSettled(
this.providerEntries.map((provider) => provider.provider.initialize?.(context)),
this.providerEntries.map((provider) => provider.provider.initialize?.(context, domain)),
);
throwAggregateErrorFromPromiseResults(result, this.providerEntries);
}
Expand Down
34 changes: 34 additions & 0 deletions packages/server/test/multi-provider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
FirstSuccessfulStrategy,
ComparisonStrategy,
} from '../src';
import { legacyInitializeProvider } from '../../shared/test/legacy-initialize-provider';

class TestProvider implements Provider {
public metadata: ProviderMetadata = {
Expand Down Expand Up @@ -162,6 +163,39 @@ describe('MultiProvider', () => {
expect(initializations).toBe(2);
});

it('forwards domain to legacy single-argument child providers without error', async () => {
Comment thread
jonathannorris marked this conversation as resolved.
const legacyProvider1 = legacyInitializeProvider(
{ runsOn: 'server', asyncResolvers: true, name: 'LegacyInitTestProvider' },
{ events: new OpenFeatureEventEmitter() },
);
const legacyProvider2 = legacyInitializeProvider(
{ runsOn: 'server', asyncResolvers: true, name: 'LegacyInitTestProvider' },
{ events: new OpenFeatureEventEmitter() },
);
const multiProvider = new MultiProvider([
{ provider: legacyProvider1 as unknown as Provider },
{ provider: legacyProvider2 as unknown as Provider },
]);

await multiProvider.initialize({ targetingKey: 'user' }, 'my-domain');

expect(legacyProvider1.initializeCalls).toBe(1);
expect(legacyProvider2.initializeCalls).toBe(1);
expect(legacyProvider1.lastContext).toEqual({ targetingKey: 'user' });
expect(legacyProvider2.lastContext).toEqual({ targetingKey: 'user' });
});

it('forwards domain to child provider initialize', async () => {
const provider1 = new TestProvider();
const provider2 = new TestProvider();
const multiProvider = new MultiProvider([{ provider: provider1 }, { provider: provider2 }]);

await multiProvider.initialize({ targetingKey: 'user' }, 'my-domain');

expect(provider1.initialize).toHaveBeenCalledWith({ targetingKey: 'user' }, 'my-domain');
expect(provider2.initialize).toHaveBeenCalledWith({ targetingKey: 'user' }, 'my-domain');
});

it('throws error if a provider errors on initialization', async () => {
const provider1 = new TestProvider();
const provider2 = new TestProvider();
Expand Down
85 changes: 83 additions & 2 deletions packages/server/test/open-feature.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { Paradigm } from '@openfeature/core';
import type { Provider, ProviderStatus } from '../src';
import { OpenFeature, OpenFeatureAPI } from '../src';
import type { Provider } from '../src';
import { OpenFeature, OpenFeatureAPI, ProviderStatus } from '../src';
import { OpenFeatureClient } from '../src/client/internal/open-feature-client';
import { legacyInitializeProvider } from '../../shared/test/legacy-initialize-provider';

const mockProvider = (config?: { initialStatus?: ProviderStatus; runsOn?: Paradigm }) => {
return {
Expand Down Expand Up @@ -57,6 +58,41 @@
expect(OpenFeature.providerMetadata.name).toBe('mock-events-success');
expect(provider.initialize).toHaveBeenCalled();
});

it('MUST supply the bound domain to initialize when registering a domain-scoped provider', () => {
const domain = 'my-domain';
const provider = { ...mockProvider(), domainScoped: true } as Provider;
const spy = jest.spyOn(provider, 'initialize');
OpenFeature.setProvider(domain, provider);
expect(spy).toHaveBeenCalledWith({}, domain);
});

it('MUST not supply a domain to initialize for the default provider', () => {
const provider = mockProvider();
const spy = jest.spyOn(provider, 'initialize');
OpenFeature.setProvider(provider);
expect(spy).toHaveBeenCalledWith({}, undefined);
});

it('initializes legacy single-argument providers when bound to a domain', async () => {
const domain = 'my-domain';
const context = { targetingKey: 'user' };
const legacyProvider = legacyInitializeProvider({ runsOn: 'server', asyncResolvers: true });
OpenFeature.setContext(context);

await expect(
OpenFeature.setProviderAndWait(domain, legacyProvider as unknown as Provider),
).resolves.toBeUndefined();
expect(legacyProvider.lastContext).toEqual(context);
expect(OpenFeature.getClient(domain).providerStatus).toEqual(ProviderStatus.READY);
});

it('initializes legacy single-argument providers as the default provider', async () => {
const legacyProvider = legacyInitializeProvider({ runsOn: 'server', asyncResolvers: true });

await expect(OpenFeature.setProviderAndWait(legacyProvider as unknown as Provider)).resolves.toBeUndefined();
expect(OpenFeature.getClient().providerStatus).toEqual(ProviderStatus.READY);
});
});

describe('Requirement 1.1.2.3', () => {
Expand All @@ -68,6 +104,51 @@
expect(provider.onClose).toHaveBeenCalled();
});
});

describe('Condition 1.1.8', () => {
it('MUST NOT bind a domain-scoped provider instance to more than one domain', () => {
const provider = { ...mockProvider(), domainScoped: true } as Provider;

OpenFeature.setProvider('domain-a', provider);
expect(() => OpenFeature.setProvider('domain-b', provider)).toThrow(
"Cannot bind domain-scoped provider 'mock-events-success' to more than one domain.",
);
expect(OpenFeature.getProvider('domain-a')).toBe(provider);
expect(OpenFeature.getProvider('domain-b').metadata.name).not.toBe(provider.metadata.name);
});

it('MUST NOT bind a domain-scoped default provider to a named domain', () => {
const provider = { ...mockProvider(), domainScoped: true } as Provider;

OpenFeature.setProvider(provider);
expect(() => OpenFeature.setProvider('domain-a', provider)).toThrow(
"Cannot bind domain-scoped provider 'mock-events-success' to more than one domain.",
);
expect(OpenFeature.getProvider()).toBe(provider);
expect(OpenFeature.getProvider('domain-a').metadata.name).not.toBe(provider.metadata.name);

Check failure on line 128 in packages/server/test/open-feature.spec.ts

View workflow job for this annotation

GitHub Actions / build-test (24.x)

OpenFeature › Requirement 1.1.2 › Condition 1.1.8 › MUST NOT bind a domain-scoped default provider to a named domain

expect(received).not.toBe(expected) // Object.is equality Expected: not "mock-events-success" at Object.<anonymous> (packages/server/test/open-feature.spec.ts:128:71)

Check failure on line 128 in packages/server/test/open-feature.spec.ts

View workflow job for this annotation

GitHub Actions / build-test (22.x)

OpenFeature › Requirement 1.1.2 › Condition 1.1.8 › MUST NOT bind a domain-scoped default provider to a named domain

expect(received).not.toBe(expected) // Object.is equality Expected: not "mock-events-success" at Object.<anonymous> (packages/server/test/open-feature.spec.ts:128:71)

Check failure on line 128 in packages/server/test/open-feature.spec.ts

View workflow job for this annotation

GitHub Actions / build-test (20.x)

OpenFeature › Requirement 1.1.2 › Condition 1.1.8 › MUST NOT bind a domain-scoped default provider to a named domain

expect(received).not.toBe(expected) // Object.is equality Expected: not "mock-events-success" at Object.<anonymous> (packages/server/test/open-feature.spec.ts:128:71)
});

it('MUST NOT bind a domain-scoped provider as default when already bound to a domain', () => {
const provider = { ...mockProvider(), domainScoped: true } as Provider;

OpenFeature.setProvider('domain-a', provider);
expect(() => OpenFeature.setProvider(provider)).toThrow(
"Cannot bind domain-scoped provider 'mock-events-success' to more than one domain.",
);
expect(OpenFeature.getProvider('domain-a')).toBe(provider);
});

it('allows a non-domain-scoped provider to back multiple domains', async () => {
const provider = { ...mockProvider(), onClose: jest.fn() };

await OpenFeature.setProviderAndWait('domain1', provider);
await OpenFeature.setProviderAndWait('domain2', provider);

expect(OpenFeature.getProvider('domain1')).toBe(provider);
expect(OpenFeature.getProvider('domain2')).toBe(provider);
expect(provider.initialize).toHaveBeenCalledTimes(1);
});
});
});

describe('Requirement 1.1.3', () => {
Expand Down
13 changes: 12 additions & 1 deletion packages/shared/src/open-feature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,12 @@ export abstract class OpenFeatureCommonAPI<

// ignore no-ops
if (oldProvider === provider) {
// domain-scoped providers may already be the default; an explicit domain bind is not a no-op
if (provider.domainScoped && domain && this._domainScopedProviders.get(domain)?.provider !== provider) {
throw new GeneralError(
`Cannot bind domain-scoped provider '${provider.metadata.name}' to more than one domain.`,
);
}
this._logger.debug('Provider is already set, ignoring setProvider call');
return;
}
Expand All @@ -241,6 +247,10 @@ export abstract class OpenFeatureCommonAPI<
throw new GeneralError(`Provider '${provider.metadata.name}' is intended for use on the ${provider.runsOn}.`);
}

if (provider.domainScoped && this.allProviders.includes(provider)) {
throw new GeneralError(`Cannot bind domain-scoped provider '${provider.metadata.name}' to more than one domain.`);
}

Comment on lines 234 to +253

@toddbaert toddbaert Jul 3, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I think the "domainScoped" check could be consolidated into a single upfront check that inspects both _defaultProvider and _domainScopedProviders directly for this instance and rejects if any binding differs from the current one, letting the no-op branch stay focused on being a no-op. Just for consideration; happy either way... I think it would be more lines of code, but it would lock it to a single place... maybe extracted to a method.

Totally optional, only do it if you agree.

const emitters = this.getAssociatedEventEmitters(domain);

let initializationPromise: Promise<void> | void = undefined;
Expand All @@ -252,8 +262,9 @@ export abstract class OpenFeatureCommonAPI<

// initialize the provider if it implements "initialize" and it's not already registered
if (typeof provider.initialize === 'function' && !this.allProviders.includes(provider)) {
const initContext = domain ? (this._domainScopedContext.get(domain) ?? this._context) : this._context;
initializationPromise = provider
.initialize?.(domain ? (this._domainScopedContext.get(domain) ?? this._context) : this._context)
.initialize?.(initContext, domain)
?.then(() => {
Comment thread
jonathannorris marked this conversation as resolved.
wrappedProvider.status = this._statusEnumType.READY;
// fetch the most recent event emitters, some may have been added during init
Expand Down
11 changes: 9 additions & 2 deletions packages/shared/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ export interface CommonProvider<S extends ClientProviderStatus | ServerProviderS
*/
readonly runsOn?: Paradigm;

/**
* When true, the provider maintains state specific to a single domain (such as a
* persistent cache) and the SDK will bind it to at most one domain.
*/
readonly domainScoped?: boolean;

// TODO: in the future we could make this a never to force provider to remove it.
/**
* @deprecated the SDK now maintains the provider's state; there's no need for providers to implement this field.
Expand Down Expand Up @@ -123,9 +129,10 @@ export interface CommonProvider<S extends ClientProviderStatus | ServerProviderS
* When the returned promise resolves, the SDK fires the ProviderEvents.Ready event.
* If the returned promise rejects, the SDK fires the ProviderEvents.Error event.
* Use this function to perform any context-dependent setup within the provider.
* @param context
* @param context the global evaluation context
* @param domain the bound domain, if any
*/
initialize?(context?: EvaluationContext): Promise<void>;
initialize?(context?: EvaluationContext, domain?: string): Promise<void>;
Comment thread
jonathannorris marked this conversation as resolved.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we should make this an initialization config object so we can extend it in the future without breaking anything.

@toddbaert toddbaert Jul 3, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it matters as much here due to overloading, but it's possible and maybe a good idea. We could have an InitializationContext (we already have a HookContext that is comparable). "Context" is already a bit overloaded, but this is mostly a provider author interface so I'm not that worried about that.


/**
* Track a user action or application state, usually representing a business objective or outcome.
Expand Down
73 changes: 73 additions & 0 deletions packages/shared/test/legacy-initialize-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { EvaluationContext, Paradigm } from '../src';

type LegacyInitializeProviderOptions = {
runsOn: Paradigm;
name?: string;
/** When true, resolution stubs return promises (server SDK). Default false (web SDK). */
asyncResolvers?: boolean;
};

type LegacyInitializeProviderExtras = {
events: unknown;
hooks?: unknown[];
track?: jest.Mock;
};

type LegacyInitializeProviderResolvers = {
resolveBooleanEvaluation: jest.Mock;
resolveStringEvaluation: jest.Mock;
resolveNumberEvaluation: jest.Mock;
resolveObjectEvaluation: jest.Mock;
};

export type LegacyInitializeProvider = LegacyInitializeProviderResolvers & {
metadata: { name: string };
runsOn: Paradigm;
lastContext?: EvaluationContext;
initializeCalls: number;
initialize: (context?: EvaluationContext) => Promise<void>;
};

function createResolverStubs(asyncResolvers: boolean): LegacyInitializeProviderResolvers {
const mockValue = asyncResolvers
? <T>(value: T) => jest.fn().mockResolvedValue(value)
: <T>(value: T) => jest.fn().mockReturnValue(value);

return {
resolveBooleanEvaluation: mockValue({ value: false }),
resolveStringEvaluation: mockValue({ value: '' }),
resolveNumberEvaluation: mockValue({ value: 0 }),
resolveObjectEvaluation: mockValue({ value: {} }),
};
}

/**

Check warning on line 44 in packages/shared/test/legacy-initialize-provider.ts

View workflow job for this annotation

GitHub Actions / format-lint

Missing JSDoc @returns declaration

Check warning on line 44 in packages/shared/test/legacy-initialize-provider.ts

View workflow job for this annotation

GitHub Actions / format-lint

Missing JSDoc @param "extras" declaration

Check warning on line 44 in packages/shared/test/legacy-initialize-provider.ts

View workflow job for this annotation

GitHub Actions / format-lint

Missing JSDoc @param "options" declaration
* Provider with a single-argument initialize that ignores any extra arguments passed by the SDK.
* Pass optional extras when MultiProvider needs events, hooks, or track stubs on the child.
*/
Comment on lines +44 to +47

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/**
* Provider with a single-argument initialize that ignores any extra arguments passed by the SDK.
* Pass optional extras when MultiProvider needs events, hooks, or track stubs on the child.
*/
/**
* Provider with a single-argument initialize that ignores any extra arguments passed by the SDK.
* Pass optional extras when MultiProvider needs events, hooks, or track stubs on the child.
* @param options provider configuration (paradigm, name, resolver mode)
* @param extras optional MultiProvider child stubs (events, hooks, track)
* @returns a single-argument-initialize provider
*/

Fixes a lint.

export function legacyInitializeProvider(
options: LegacyInitializeProviderOptions,
extras?: LegacyInitializeProviderExtras,
): LegacyInitializeProvider {
const provider: LegacyInitializeProvider = {
metadata: { name: options.name ?? 'legacy-init' },
runsOn: options.runsOn,
lastContext: undefined,
initializeCalls: 0,
async initialize(context?: EvaluationContext): Promise<void> {
this.lastContext = context;
this.initializeCalls++;
},
...createResolverStubs(options.asyncResolvers ?? false),
};

if (extras) {
Object.assign(provider, {
events: extras.events,
hooks: extras.hooks ?? [],
track: extras.track ?? jest.fn(),
});
}

return provider;
}
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,9 @@ export class MultiProvider implements Provider {
Object.freeze(this.providerEntriesByName);
}

async initialize(context?: EvaluationContext): Promise<void> {
async initialize(context?: EvaluationContext, domain?: string): Promise<void> {
const result = await Promise.allSettled(
this.providerEntries.map((provider) => provider.provider.initialize?.(context)),
this.providerEntries.map((provider) => provider.provider.initialize?.(context, domain)),
);
throwAggregateErrorFromPromiseResults(result, this.providerEntries);
}
Expand Down
4 changes: 2 additions & 2 deletions packages/web/test/evaluation-context.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ describe('Evaluation Context', () => {
const context: EvaluationContext = { property1: false };
const provider = new MockProvider();
await OpenFeature.setProviderAndWait(provider, context);
expect(initializeMock).toHaveBeenCalledWith(context);
expect(initializeMock).toHaveBeenCalledWith(context, undefined);
expect(OpenFeature.getContext()).toEqual(context);
});

Expand All @@ -95,7 +95,7 @@ describe('Evaluation Context', () => {
await OpenFeature.setProviderAndWait(domain, provider, context);
expect(OpenFeature.getContext()).toEqual({});
expect(OpenFeature.getContext(domain)).toEqual(context);
expect(initializeMock).toHaveBeenCalledWith(context);
expect(initializeMock).toHaveBeenCalledWith(context, domain);
});
});

Expand Down
34 changes: 34 additions & 0 deletions packages/web/test/multi-provider-web.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
FirstSuccessfulStrategy,
ComparisonStrategy,
} from '../src';
import { legacyInitializeProvider } from '../../shared/test/legacy-initialize-provider';

class TestProvider implements Provider {
public metadata: ProviderMetadata = {
Expand Down Expand Up @@ -162,6 +163,39 @@ describe('MultiProvider', () => {
expect(initializations).toBe(2);
});

it('forwards domain to legacy single-argument child providers without error', async () => {
Comment thread
jonathannorris marked this conversation as resolved.
const legacyProvider1 = legacyInitializeProvider(
{ runsOn: 'client', name: 'LegacyInitTestProvider' },
{ events: new OpenFeatureEventEmitter() },
);
const legacyProvider2 = legacyInitializeProvider(
{ runsOn: 'client', name: 'LegacyInitTestProvider' },
{ events: new OpenFeatureEventEmitter() },
);
const multiProvider = new MultiProvider([
{ provider: legacyProvider1 as unknown as Provider },
{ provider: legacyProvider2 as unknown as Provider },
]);

await multiProvider.initialize({ targetingKey: 'user' }, 'my-domain');

expect(legacyProvider1.initializeCalls).toBe(1);
expect(legacyProvider2.initializeCalls).toBe(1);
expect(legacyProvider1.lastContext).toEqual({ targetingKey: 'user' });
expect(legacyProvider2.lastContext).toEqual({ targetingKey: 'user' });
});

it('forwards domain to child provider initialize', async () => {
const provider1 = new TestProvider();
const provider2 = new TestProvider();
const multiProvider = new MultiProvider([{ provider: provider1 }, { provider: provider2 }]);

await multiProvider.initialize({ targetingKey: 'user' }, 'my-domain');

expect(provider1.initialize).toHaveBeenCalledWith({ targetingKey: 'user' }, 'my-domain');
expect(provider2.initialize).toHaveBeenCalledWith({ targetingKey: 'user' }, 'my-domain');
});

it('throws error if a provider errors on initialization', async () => {
const provider1 = new TestProvider();
const provider2 = new TestProvider();
Expand Down
Loading
Loading