diff --git a/packages/angular/projects/angular-sdk/src/lib/feature-flag.directive.ts b/packages/angular/projects/angular-sdk/src/lib/feature-flag.directive.ts index d390cd5d9..34b4b93ce 100644 --- a/packages/angular/projects/angular-sdk/src/lib/feature-flag.directive.ts +++ b/packages/angular/projects/angular-sdk/src/lib/feature-flag.directive.ts @@ -24,6 +24,7 @@ import { OpenFeature, StringFlagKey, } from '@openfeature/web-sdk'; +import { setAngularFrameworkMetadata } from './framework-metadata'; /** * Represents the template context provided by feature flag structural directives @@ -132,7 +133,7 @@ export abstract class FeatureFlagDirective implements OnIni if (this._client) { this.disposeClient(this._client); } - this._client = OpenFeature.getClient(this._featureFlagDomain); + this._client = setAngularFrameworkMetadata(OpenFeature.getClient(this._featureFlagDomain)); const baseHandler = () => { const result = this.getFlagDetails(this._featureFlagKey, this._featureFlagDefault); diff --git a/packages/angular/projects/angular-sdk/src/lib/feature-flag.service.spec.ts b/packages/angular/projects/angular-sdk/src/lib/feature-flag.service.spec.ts index 139fe27cb..96d2717bc 100644 --- a/packages/angular/projects/angular-sdk/src/lib/feature-flag.service.spec.ts +++ b/packages/angular/projects/angular-sdk/src/lib/feature-flag.service.spec.ts @@ -8,6 +8,7 @@ import { AsyncPipe } from '@angular/common'; import { TestingProvider } from '../test/test.utils'; import { OpenFeatureModule } from './open-feature.module'; import { toSignal } from '@angular/core/rxjs-interop'; +import { vi } from 'vitest'; const FLAG_KEY = 'thumbs'; @@ -114,6 +115,7 @@ describe('FeatureFlagService', () => { afterEach(async () => { await OpenFeature.close(); + OpenFeature.clearHooks(); await OpenFeature.setContext({}); currentTestComponentFixture?.destroy(); currentContextChangeDisabledComponentFixture?.destroy(); @@ -129,6 +131,27 @@ describe('FeatureFlagService', () => { expect(observableValue?.textContent).toBe('👍'); }); + it('should surface angular metadata in hook contexts', async () => { + const hook = { before: vi.fn() }; + + OpenFeature.addHooks(hook); + await createTestingModule(); + service = TestBed.inject(FeatureFlagService); + + await firstValueFrom(service.getBooleanDetails(FLAG_KEY, false)); + + expect(hook.before).toHaveBeenCalledWith( + expect.objectContaining({ + clientMetadata: expect.objectContaining({ + sdk: 'js-web', + paradigm: 'client', + framework: 'angular', + }), + }), + undefined, + ); + }); + it('should render updated value after delay', async () => { const delay = 50; await createTestingModule({ providerInitDelay: delay }); diff --git a/packages/angular/projects/angular-sdk/src/lib/feature-flag.service.ts b/packages/angular/projects/angular-sdk/src/lib/feature-flag.service.ts index 1ad5ba578..bdb77d85f 100644 --- a/packages/angular/projects/angular-sdk/src/lib/feature-flag.service.ts +++ b/packages/angular/projects/angular-sdk/src/lib/feature-flag.service.ts @@ -14,6 +14,7 @@ import { ProviderStatus, StringFlagKey, } from '@openfeature/web-sdk'; +import { setAngularFrameworkMetadata } from './framework-metadata'; import { isEqual } from './internal/is-equal'; export type AngularFlagEvaluationOptions = { @@ -217,7 +218,7 @@ export class FeatureFlagService { domain: string | undefined, options?: AngularFlagEvaluationOptions, ): Observable> { - const client = domain ? OpenFeature.getClient(domain) : OpenFeature.getClient(); + const client = setAngularFrameworkMetadata(OpenFeature.getClient(domain || undefined)); return new Observable>((subscriber) => { let currentResult: EvaluationDetails | undefined = undefined; diff --git a/packages/angular/projects/angular-sdk/src/lib/framework-metadata.ts b/packages/angular/projects/angular-sdk/src/lib/framework-metadata.ts new file mode 100644 index 000000000..b69f11a03 --- /dev/null +++ b/packages/angular/projects/angular-sdk/src/lib/framework-metadata.ts @@ -0,0 +1,15 @@ +import type { Client } from '@openfeature/web-sdk'; + +type FrameworkMetadataClient = Client & { + setFrameworkMetadata?: (framework: 'angular') => Client; +}; + +/** + * Marks an SDK-owned web client as Angular-backed while preserving instance identity. + * @param {Client} client client instance to update + * @returns {Client} the same client instance + */ +export function setAngularFrameworkMetadata(client: Client): Client { + (client as FrameworkMetadataClient).setFrameworkMetadata?.('angular'); + return client; +} diff --git a/packages/nest/src/framework-metadata.ts b/packages/nest/src/framework-metadata.ts new file mode 100644 index 000000000..8807a1d6b --- /dev/null +++ b/packages/nest/src/framework-metadata.ts @@ -0,0 +1,15 @@ +import type { Client } from '@openfeature/server-sdk'; + +type FrameworkMetadataClient = Client & { + setFrameworkMetadata?: (framework: 'nest') => Client; +}; + +/** + * Marks an SDK-owned server client as Nest-backed while preserving instance identity. + * @param {Client} client client instance to update + * @returns {Client} the same client instance + */ +export function setNestFrameworkMetadata(client: Client): Client { + (client as FrameworkMetadataClient).setFrameworkMetadata?.('nest'); + return client; +} diff --git a/packages/nest/src/open-feature.module.ts b/packages/nest/src/open-feature.module.ts index e20065257..75d9b6c23 100644 --- a/packages/nest/src/open-feature.module.ts +++ b/packages/nest/src/open-feature.module.ts @@ -20,6 +20,7 @@ import type { ContextFactory } from './context-factory'; import { ContextFactoryToken } from './context-factory'; import { APP_INTERCEPTOR } from '@nestjs/core'; import { EvaluationContextInterceptor } from './evaluation-context-interceptor'; +import { setNestFrameworkMetadata } from './framework-metadata'; import { ShutdownService } from './shutdown.service'; /** @@ -45,7 +46,7 @@ export class OpenFeatureModule { const clientValueProviders: NestFactoryProvider[] = [ { provide: getOpenFeatureClientToken(), - useFactory: () => OpenFeature.getClient(), + useFactory: () => setNestFrameworkMetadata(OpenFeature.getClient()), }, ]; @@ -58,7 +59,7 @@ export class OpenFeatureModule { OpenFeature.setProvider(domain, provider); clientValueProviders.push({ provide: getOpenFeatureClientToken(domain), - useFactory: () => OpenFeature.getClient(domain), + useFactory: () => setNestFrameworkMetadata(OpenFeature.getClient(domain)), }); }); } diff --git a/packages/nest/src/utils.ts b/packages/nest/src/utils.ts index aec145b61..0ed956d65 100644 --- a/packages/nest/src/utils.ts +++ b/packages/nest/src/utils.ts @@ -1,5 +1,6 @@ import type { Client, EvaluationContext } from '@openfeature/server-sdk'; import { OpenFeature } from '@openfeature/server-sdk'; +import { setNestFrameworkMetadata } from './framework-metadata'; /** * Returns a domain scoped or the default OpenFeature client with the given context. @@ -8,5 +9,5 @@ import { OpenFeature } from '@openfeature/server-sdk'; * @returns {Client} The OpenFeature client. */ export function getClientForEvaluation(domain?: string, context?: EvaluationContext) { - return domain ? OpenFeature.getClient(domain, context) : OpenFeature.getClient(context); + return setNestFrameworkMetadata(domain ? OpenFeature.getClient(domain, context) : OpenFeature.getClient(context)); } diff --git a/packages/nest/test/open-feature.module.spec.ts b/packages/nest/test/open-feature.module.spec.ts index fd165488b..38e23c586 100644 --- a/packages/nest/test/open-feature.module.spec.ts +++ b/packages/nest/test/open-feature.module.spec.ts @@ -3,7 +3,7 @@ import { Test } from '@nestjs/testing'; import { getOpenFeatureClientToken, OpenFeatureModule, ServerProviderEvents } from '../src'; import type { Client } from '@openfeature/server-sdk'; import { OpenFeature } from '@openfeature/server-sdk'; -import { getOpenFeatureDefaultTestModule } from './fixtures'; +import { defaultProvider, getOpenFeatureDefaultTestModule } from './fixtures'; describe('OpenFeatureModule', () => { let moduleRef: TestingModule; @@ -49,6 +49,48 @@ describe('OpenFeatureModule', () => { expect(client).toBeDefined(); expect(await client.getStringValue('testStringFlag', '')).toEqual('expected-string-value-scoped'); }); + + it('should expose nest framework metadata on injected clients', () => { + const defaultClient = moduleRef.get(getOpenFeatureClientToken()); + const scopedClient = moduleRef.get(getOpenFeatureClientToken('domainScopedClient')); + + expect(defaultClient.metadata).toMatchObject({ + sdk: 'js-server', + paradigm: 'server', + framework: 'nest', + }); + expect(scopedClient.metadata).toMatchObject({ + sdk: 'js-server', + paradigm: 'server', + framework: 'nest', + }); + }); + + it('should surface nest metadata in hook contexts', async () => { + const hook = { before: jest.fn() }; + const hookModuleRef = await Test.createTestingModule({ + imports: [OpenFeatureModule.forRoot({ defaultProvider, hooks: [hook] })], + }).compile(); + + try { + const client = hookModuleRef.get(getOpenFeatureClientToken()); + await client.getBooleanValue('testBooleanFlag', false); + + expect(hook.before).toHaveBeenCalledWith( + expect.objectContaining({ + clientMetadata: expect.objectContaining({ + sdk: 'js-server', + paradigm: 'server', + framework: 'nest', + }), + }), + undefined, + ); + } finally { + await hookModuleRef.close(); + OpenFeature.clearHooks(); + } + }); }); describe('handlers', () => { diff --git a/packages/react/src/provider/provider.tsx b/packages/react/src/provider/provider.tsx index 68746604d..3ddf9a465 100644 --- a/packages/react/src/provider/provider.tsx +++ b/packages/react/src/provider/provider.tsx @@ -4,6 +4,10 @@ import * as React from 'react'; import type { ReactFlagEvaluationOptions } from '../options'; import { Context } from '../internal'; +type FrameworkMetadataClient = Client & { + setFrameworkMetadata?: (framework: 'react') => Client; +}; + type ClientOrDomain = | { /** @@ -32,7 +36,22 @@ type ProviderProps = { * @returns {OpenFeatureProvider} context provider */ export function OpenFeatureProvider({ client, domain, children, ...options }: ProviderProps) { - const stableClient = React.useMemo(() => client || OpenFeature.getClient(domain), [client, domain]); + const stableClient = React.useMemo(() => { + if (client) { + return setReactFrameworkMetadata(client); + } + + return setReactFrameworkMetadata(OpenFeature.getClient(domain)); + }, [client, domain]); return {children}; } + +function setReactFrameworkMetadata(client: Client): Client { + // When a caller provides an existing client, preserve that instance but mark it + // as React-backed so metadata stays aligned with provider-created clients. + // The cast is needed because `setFrameworkMetadata` is an internal method on the + // SDK-owned client implementation, not part of the public `Client` interface. + (client as FrameworkMetadataClient).setFrameworkMetadata?.('react'); + return client; +} diff --git a/packages/react/test/provider.spec.tsx b/packages/react/test/provider.spec.tsx index 1a19f69e3..5883c4531 100644 --- a/packages/react/test/provider.spec.tsx +++ b/packages/react/test/provider.spec.tsx @@ -53,13 +53,14 @@ describe('OpenFeatureProvider', () => { beforeEach(async () => { await OpenFeature.clearContexts(); + OpenFeature.clearHooks(); }); describe('useOpenFeatureClient', () => { const DOMAIN = 'useOpenFeatureClient'; describe('client specified', () => { - it('should return client from provider', () => { + it('should return client from provider with react metadata', () => { const client = OpenFeature.getClient(DOMAIN); const wrapper = ({ children }: Parameters[0]) => ( @@ -69,6 +70,12 @@ describe('OpenFeatureProvider', () => { const { result } = renderHook(() => useOpenFeatureClient(), { wrapper }); expect(result.current).toEqual(client); + expect(result.current.metadata).toMatchObject({ + domain: DOMAIN, + sdk: 'js-web', + paradigm: 'client', + framework: 'react', + }); }); }); @@ -81,6 +88,9 @@ describe('OpenFeatureProvider', () => { const { result } = renderHook(() => useOpenFeatureClient(), { wrapper }); expect(result.current.metadata.domain).toEqual(DOMAIN); + expect(result.current.metadata.sdk).toEqual('js-web'); + expect(result.current.metadata.paradigm).toEqual('client'); + expect(result.current.metadata.framework).toEqual('react'); }); it('should return a stable client across renders', () => { @@ -96,6 +106,46 @@ describe('OpenFeatureProvider', () => { expect(firstClient).toBe(secondClient); }); + + it('should surface react metadata in hook contexts', async () => { + const hook = { before: jest.fn() }; + + OpenFeature.setProvider( + DOMAIN, + new InMemoryProvider({ + greeting: { + disabled: false, + variants: { default: 'hello' }, + defaultVariant: 'default', + }, + }), + ); + + const wrapper = ({ children }: Parameters[0]) => ( + {children} + ); + + const { result } = renderHook( + () => + useStringFlagValue('greeting', 'fallback', { + hooks: [hook], + }), + { wrapper }, + ); + + await waitFor(() => expect(result.current).toEqual('hello')); + expect(hook.before).toHaveBeenCalledWith( + expect.objectContaining({ + clientMetadata: expect.objectContaining({ + domain: DOMAIN, + sdk: 'js-web', + paradigm: 'client', + framework: 'react', + }), + }), + undefined, + ); + }); }); }); diff --git a/packages/server/src/client/client.ts b/packages/server/src/client/client.ts index f2470d509..cd7e94ed7 100644 --- a/packages/server/src/client/client.ts +++ b/packages/server/src/client/client.ts @@ -1,4 +1,4 @@ -import type { ClientMetadata, EvaluationLifeCycle, Eventing, ManageContext, ManageLogger } from '@openfeature/core'; +import type { EvaluationLifeCycle, Eventing, ManageContext, ManageLogger, MetadataClient } from '@openfeature/core'; import type { Features } from '../evaluation'; import type { ProviderStatus } from '../provider'; import type { ProviderEvents } from '../events'; @@ -11,8 +11,8 @@ export interface Client ManageContext, ManageLogger, Tracking, - Eventing { - readonly metadata: ClientMetadata; + Eventing, + MetadataClient { /** * Returns the status of the associated provider. */ diff --git a/packages/server/src/client/internal/open-feature-client.ts b/packages/server/src/client/internal/open-feature-client.ts index 628c77b4c..0556a235a 100644 --- a/packages/server/src/client/internal/open-feature-client.ts +++ b/packages/server/src/client/internal/open-feature-client.ts @@ -1,4 +1,5 @@ import type { + ClientFramework, ClientMetadata, EvaluationContext, EvaluationDetails, @@ -50,6 +51,7 @@ export class OpenFeatureClient implements Client { private _context: EvaluationContext; private _hooks: Hook[] = []; private _clientLogger?: Logger; + private _framework?: ClientFramework; constructor( // we always want the client to use the current provider, @@ -73,10 +75,29 @@ export class OpenFeatureClient implements Client { name: this.options.domain ?? this.options.name, domain: this.options.domain ?? this.options.name, version: this.options.version, + sdk: 'js-server', + paradigm: 'server', + framework: this._framework, providerMetadata: this.providerAccessor().metadata, }; } + /** + * Sets framework metadata on an existing SDK-owned client instance. + * + * This is used by framework wrappers that must preserve a pre-created client + * instance instead of constructing a new framework-aware client. Framework + * packages cast to this internal method because it is intentionally not part + * of the public `Client` interface. + * @param {ClientFramework} framework framework metadata to expose + * @returns {this} the updated client + * @internal + */ + setFrameworkMetadata(framework: ClientFramework): this { + this._framework = framework; + return this; + } + get providerStatus(): ProviderStatus { return this.providerStatusAccessor(); } diff --git a/packages/server/test/client.spec.ts b/packages/server/test/client.spec.ts index 18747cd0a..97996204e 100644 --- a/packages/server/test/client.spec.ts +++ b/packages/server/test/client.spec.ts @@ -184,6 +184,11 @@ describe('OpenFeatureClient', () => { it('should have metadata accessor with domain', () => { expect(client.metadata.domain).toEqual(domain); }); + + it('should expose sdk family and paradigm in metadata', () => { + expect(client.metadata.sdk).toEqual('js-server'); + expect(client.metadata.paradigm).toEqual('server'); + }); }); describe('Requirement 1.3.1, 1.3.2.1', () => { diff --git a/packages/server/test/open-feature.spec.ts b/packages/server/test/open-feature.spec.ts index fe41f8354..eb63983b3 100644 --- a/packages/server/test/open-feature.spec.ts +++ b/packages/server/test/open-feature.spec.ts @@ -190,6 +190,48 @@ describe('OpenFeature', () => { const client = OpenFeature.addHooks().clearHooks().setLogger(console).getClient(); expect(client).toBeDefined(); }); + + it('should support all getClient overload forms', () => { + const contextOnly = { targetingKey: 'context-only' }; + const domainContext = { targetingKey: 'domain-context' }; + const versionContext = { targetingKey: 'version-context' }; + + const defaultClient = OpenFeature.getClient(); + const contextClient = OpenFeature.getClient(contextOnly); + const domainClient = OpenFeature.getClient('domain-only'); + const domainContextClient = OpenFeature.getClient('domain-context', domainContext); + const legacyVersionClient = OpenFeature.getClient('legacy-version', '1.2.3', versionContext); + + expect(defaultClient.metadata).toMatchObject({ + sdk: 'js-server', + paradigm: 'server', + }); + expect(defaultClient.getContext()).toEqual({}); + expect(contextClient.metadata).toMatchObject({ + sdk: 'js-server', + paradigm: 'server', + }); + expect(contextClient.getContext()).toEqual(contextOnly); + expect(domainClient.metadata).toMatchObject({ + domain: 'domain-only', + sdk: 'js-server', + paradigm: 'server', + }); + expect(domainClient.getContext()).toEqual({}); + expect(domainContextClient.metadata).toMatchObject({ + domain: 'domain-context', + sdk: 'js-server', + paradigm: 'server', + }); + expect(domainContextClient.getContext()).toEqual(domainContext); + expect(legacyVersionClient.metadata).toMatchObject({ + domain: 'legacy-version', + version: '1.2.3', + sdk: 'js-server', + paradigm: 'server', + }); + expect(legacyVersionClient.getContext()).toEqual(versionContext); + }); }); describe('Requirement 1.6.1', () => { diff --git a/packages/shared/src/client/client.ts b/packages/shared/src/client/client.ts index f487e9ddb..95a7046d1 100644 --- a/packages/shared/src/client/client.ts +++ b/packages/shared/src/client/client.ts @@ -1,4 +1,8 @@ import type { ProviderMetadata } from '../provider/provider'; +import type { Paradigm } from '../types/paradigm'; + +export type ClientSdk = 'js-web' | 'js-server'; +export type ClientFramework = 'react' | 'angular' | 'nest'; export interface ClientMetadata { /** @@ -7,5 +11,12 @@ export interface ClientMetadata { readonly name?: string; readonly domain?: string; readonly version?: string; + readonly sdk?: ClientSdk; + readonly paradigm?: Paradigm; + readonly framework?: ClientFramework; readonly providerMetadata: ProviderMetadata; } + +export interface MetadataClient { + readonly metadata: ClientMetadata; +} diff --git a/packages/web/src/client/client.ts b/packages/web/src/client/client.ts index 93f2cad19..1f19c7bf3 100644 --- a/packages/web/src/client/client.ts +++ b/packages/web/src/client/client.ts @@ -1,12 +1,17 @@ -import type { ClientMetadata, EvaluationLifeCycle, Eventing, ManageLogger } from '@openfeature/core'; +import type { EvaluationLifeCycle, Eventing, ManageLogger, MetadataClient } from '@openfeature/core'; import type { Features } from '../evaluation'; import type { ProviderStatus } from '../provider'; import type { ProviderEvents } from '../events'; import type { Tracking } from '../tracking'; export interface Client - extends EvaluationLifeCycle, Features, ManageLogger, Eventing, Tracking { - readonly metadata: ClientMetadata; + extends + EvaluationLifeCycle, + Features, + ManageLogger, + Eventing, + Tracking, + MetadataClient { /** * Returns the status of the associated provider. */ diff --git a/packages/web/src/client/internal/open-feature-client.ts b/packages/web/src/client/internal/open-feature-client.ts index 3f3855ee9..6d37b6845 100644 --- a/packages/web/src/client/internal/open-feature-client.ts +++ b/packages/web/src/client/internal/open-feature-client.ts @@ -1,4 +1,5 @@ import type { + ClientFramework, ClientMetadata, EvaluationContext, EvaluationDetails, @@ -49,6 +50,7 @@ type OpenFeatureClientOptions = { export class OpenFeatureClient implements Client { private _hooks: Hook[] = []; private _clientLogger?: Logger; + private _framework?: ClientFramework; constructor( // functions are passed here to make sure that these values are always up to date, @@ -68,10 +70,28 @@ export class OpenFeatureClient implements Client { name: this.options.domain ?? this.options.name, domain: this.options.domain ?? this.options.name, version: this.options.version, + sdk: 'js-web', + paradigm: 'client', + framework: this._framework, providerMetadata: this.providerAccessor().metadata, }; } + /** + * Sets framework metadata on an existing SDK-owned client instance. + * + * This is needed when a framework wrapper receives a pre-created client and must + * preserve that instance's identity instead of allocating a new framework-aware client. + * The React provider uses this for its `client` prop path. + * @param {ClientFramework} framework framework metadata to expose + * @returns {this} the updated client + * @internal + */ + setFrameworkMetadata(framework: ClientFramework): this { + this._framework = framework; + return this; + } + get providerStatus(): ProviderStatus { return this.providerStatusAccessor(); } diff --git a/packages/web/test/client.spec.ts b/packages/web/test/client.spec.ts index 24837624d..196247fcb 100644 --- a/packages/web/test/client.spec.ts +++ b/packages/web/test/client.spec.ts @@ -181,6 +181,11 @@ describe('OpenFeatureClient', () => { it('should have metadata accessor with domain', () => { expect(client.metadata.domain).toEqual(domain); }); + + it('should expose sdk family and paradigm in metadata', () => { + expect(client.metadata.sdk).toEqual('js-web'); + expect(client.metadata.paradigm).toEqual('client'); + }); }); describe('Requirement 1.3.1, 1.3.2.1', () => { diff --git a/packages/web/test/open-feature.spec.ts b/packages/web/test/open-feature.spec.ts index 308d8f43b..fede1aa2e 100644 --- a/packages/web/test/open-feature.spec.ts +++ b/packages/web/test/open-feature.spec.ts @@ -191,6 +191,28 @@ describe('OpenFeature', () => { const client = OpenFeature.addHooks().clearHooks().setLogger(console).getClient(); expect(client).toBeDefined(); }); + + it('should support all getClient overload forms', () => { + const defaultClient = OpenFeature.getClient(); + const domainClient = OpenFeature.getClient('domain-only'); + const legacyVersionClient = OpenFeature.getClient('legacy-version', '1.2.3'); + + expect(defaultClient.metadata).toMatchObject({ + sdk: 'js-web', + paradigm: 'client', + }); + expect(domainClient.metadata).toMatchObject({ + domain: 'domain-only', + sdk: 'js-web', + paradigm: 'client', + }); + expect(legacyVersionClient.metadata).toMatchObject({ + domain: 'legacy-version', + version: '1.2.3', + sdk: 'js-web', + paradigm: 'client', + }); + }); }); describe('Requirement 1.6.1', () => {