From 955e50db929ab3e3b760a329eb7f966fe99bac34 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Nov 2025 15:26:24 +0000 Subject: [PATCH 1/4] Initial plan From 4b5613cfacabde956c857250896b9051fa7822fe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Nov 2025 15:48:03 +0000 Subject: [PATCH 2/4] Add flagd-selector gRPC metadata header support for in-process mode - Import Metadata from @grpc/grpc-js - Create and populate _metadata with flagd-selector header when selector is configured - Pass metadata to syncFlags gRPC call - Maintain backward compatibility by keeping selector in request field - Add comprehensive tests for selector metadata handling - Document selector implementation and deprecation in README Co-authored-by: aepfli <9987394+aepfli@users.noreply.github.com> --- libs/providers/flagd/README.md | 19 +++++ .../in-process/grpc/grpc-fetch.spec.ts | 74 +++++++++++++++++++ .../lib/service/in-process/grpc/grpc-fetch.ts | 14 +++- 3 files changed, 105 insertions(+), 2 deletions(-) diff --git a/libs/providers/flagd/README.md b/libs/providers/flagd/README.md index f7f1ecc9e..22725ca78 100644 --- a/libs/providers/flagd/README.md +++ b/libs/providers/flagd/README.md @@ -94,6 +94,25 @@ To enable this mode, you should provide a valid flag configuration file with the Offline mode uses `fs.watchFile` and polls every 5 seconds for changes to the file. This mode is useful for local development, test cases, and for offline applications. +### Selector (in-process mode only) + +The `selector` option allows filtering flag configurations from the sync service in in-process mode. This is useful when multiple flag configurations are available and you want to target a specific subset. + +```ts + OpenFeature.setProvider(new FlagdProvider({ + resolverType: 'in-process', + selector: 'app=weather', + })) +``` + +> [!NOTE] +> **Selector Implementation Details**: As of this release, the selector is sent to flagd via both: +> 1. The `flagd-selector` gRPC metadata header (new standard, see [flagd#1814](https://github.com/open-feature/flagd/issues/1814)) +> 2. The `selector` field in the `SyncFlagsRequest` message (deprecated, for backward compatibility) +> +> The request field will be removed in a future major version once the deprecation period ends. +> This dual approach ensures compatibility with both newer and older flagd versions during the transition period. + ### Default Authority usage (optional) This is useful for complex routing or service-discovery use cases that involve a proxy (e.g., Envoy). diff --git a/libs/providers/flagd/src/lib/service/in-process/grpc/grpc-fetch.spec.ts b/libs/providers/flagd/src/lib/service/in-process/grpc/grpc-fetch.spec.ts index ed9035998..854f19b1b 100644 --- a/libs/providers/flagd/src/lib/service/in-process/grpc/grpc-fetch.spec.ts +++ b/libs/providers/flagd/src/lib/service/in-process/grpc/grpc-fetch.spec.ts @@ -2,6 +2,7 @@ import { GrpcFetch } from './grpc-fetch'; import type { Config } from '../../../configuration'; import type { FlagSyncServiceClient, SyncFlagsResponse } from '../../../../proto/ts/flagd/sync/v1/sync'; import { ConnectivityState } from '@grpc/grpc-js/build/src/connectivity-state'; +import type { Metadata } from '@grpc/grpc-js'; let watchStateCallback: () => void = () => ({}); const mockChannel = { @@ -143,4 +144,77 @@ describe('grpc fetch', () => { onErrorCallback(new Error('Some connection error')); }); + + it('should send selector via flagd-selector metadata header', (done) => { + const selector = 'app=weather'; + const cfgWithSelector: Config = { ...cfg, selector }; + const flagConfiguration = '{"flags":{}}'; + + const fetch = new GrpcFetch(cfgWithSelector, serviceMock); + fetch + .connect(dataCallback, reconnectCallback, jest.fn(), disconnectCallback) + .then(() => { + try { + // Verify syncFlags was called + expect(serviceMock.syncFlags).toHaveBeenCalled(); + + // Check that both request and metadata were passed + const callArgs = (serviceMock.syncFlags as jest.Mock).mock.calls[0]; + expect(callArgs).toHaveLength(2); + + // Verify the request contains selector (for backward compatibility) + expect(callArgs[0].selector).toBe(selector); + + // Verify the metadata contains flagd-selector header + const metadata = callArgs[1] as Metadata; + expect(metadata).toBeDefined(); + expect(metadata.get('flagd-selector')).toEqual([selector]); + + done(); + } catch (err) { + done(err); + } + }) + .catch((err) => { + done(err); + }); + + onDataCallback({ flagConfiguration }); + }); + + it('should handle empty selector gracefully', (done) => { + const cfgWithoutSelector: Config = { ...cfg, selector: '' }; + const flagConfiguration = '{"flags":{}}'; + + const fetch = new GrpcFetch(cfgWithoutSelector, serviceMock); + fetch + .connect(dataCallback, reconnectCallback, jest.fn(), disconnectCallback) + .then(() => { + try { + // Verify syncFlags was called + expect(serviceMock.syncFlags).toHaveBeenCalled(); + + // Check that both request and metadata were passed + const callArgs = (serviceMock.syncFlags as jest.Mock).mock.calls[0]; + expect(callArgs).toHaveLength(2); + + // Verify the request contains empty selector + expect(callArgs[0].selector).toBe(''); + + // Verify the metadata does not contain flagd-selector header + const metadata = callArgs[1] as Metadata; + expect(metadata).toBeDefined(); + expect(metadata.get('flagd-selector')).toEqual([]); + + done(); + } catch (err) { + done(err); + } + }) + .catch((err) => { + done(err); + }); + + onDataCallback({ flagConfiguration }); + }); }); diff --git a/libs/providers/flagd/src/lib/service/in-process/grpc/grpc-fetch.ts b/libs/providers/flagd/src/lib/service/in-process/grpc/grpc-fetch.ts index 45c374b3b..8763f7295 100644 --- a/libs/providers/flagd/src/lib/service/in-process/grpc/grpc-fetch.ts +++ b/libs/providers/flagd/src/lib/service/in-process/grpc/grpc-fetch.ts @@ -1,5 +1,5 @@ import type { ClientReadableStream, ServiceError, ClientOptions } from '@grpc/grpc-js'; -import { credentials } from '@grpc/grpc-js'; +import { credentials, Metadata } from '@grpc/grpc-js'; import type { Logger } from '@openfeature/server-sdk'; import { GeneralError } from '@openfeature/server-sdk'; import type { SyncFlagsRequest, SyncFlagsResponse } from '../../../../proto/ts/flagd/sync/v1/sync'; @@ -14,6 +14,7 @@ import type { DataFetch } from '../data-fetch'; export class GrpcFetch implements DataFetch { private readonly _syncClient: FlagSyncServiceClient; private readonly _request: SyncFlagsRequest; + private readonly _metadata: Metadata; private _syncStream: ClientReadableStream | undefined; private _logger: Logger | undefined; /** @@ -47,7 +48,16 @@ export class GrpcFetch implements DataFetch { ); this._logger = logger; + // For backward compatibility during the deprecation period, we send the selector in both: + // 1. The request field (deprecated, for older flagd versions) + // 2. The gRPC metadata header 'flagd-selector' (new standard) this._request = { providerId: '', selector: selector ? selector : '' }; + + // Create metadata with the flagd-selector header + this._metadata = new Metadata(); + if (selector) { + this._metadata.set('flagd-selector', selector); + } } async connect( @@ -79,7 +89,7 @@ export class GrpcFetch implements DataFetch { this._logger?.debug('Starting gRPC sync connection'); closeStreamIfDefined(this._syncStream); try { - this._syncStream = this._syncClient.syncFlags(this._request); + this._syncStream = this._syncClient.syncFlags(this._request, this._metadata); this._syncStream.on('data', (data: SyncFlagsResponse) => { this._logger?.debug(`Received sync payload`); From b0f29f43da9eaf32a8cc36f0f189a749e797bd12 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Nov 2025 15:52:37 +0000 Subject: [PATCH 3/4] Fix code formatting with prettier Co-authored-by: aepfli <9987394+aepfli@users.noreply.github.com> --- .../in-process/grpc/grpc-fetch.spec.ts | 20 +++++++++---------- .../lib/service/in-process/grpc/grpc-fetch.ts | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/libs/providers/flagd/src/lib/service/in-process/grpc/grpc-fetch.spec.ts b/libs/providers/flagd/src/lib/service/in-process/grpc/grpc-fetch.spec.ts index 854f19b1b..513870790 100644 --- a/libs/providers/flagd/src/lib/service/in-process/grpc/grpc-fetch.spec.ts +++ b/libs/providers/flagd/src/lib/service/in-process/grpc/grpc-fetch.spec.ts @@ -149,7 +149,7 @@ describe('grpc fetch', () => { const selector = 'app=weather'; const cfgWithSelector: Config = { ...cfg, selector }; const flagConfiguration = '{"flags":{}}'; - + const fetch = new GrpcFetch(cfgWithSelector, serviceMock); fetch .connect(dataCallback, reconnectCallback, jest.fn(), disconnectCallback) @@ -157,19 +157,19 @@ describe('grpc fetch', () => { try { // Verify syncFlags was called expect(serviceMock.syncFlags).toHaveBeenCalled(); - + // Check that both request and metadata were passed const callArgs = (serviceMock.syncFlags as jest.Mock).mock.calls[0]; expect(callArgs).toHaveLength(2); - + // Verify the request contains selector (for backward compatibility) expect(callArgs[0].selector).toBe(selector); - + // Verify the metadata contains flagd-selector header const metadata = callArgs[1] as Metadata; expect(metadata).toBeDefined(); expect(metadata.get('flagd-selector')).toEqual([selector]); - + done(); } catch (err) { done(err); @@ -185,7 +185,7 @@ describe('grpc fetch', () => { it('should handle empty selector gracefully', (done) => { const cfgWithoutSelector: Config = { ...cfg, selector: '' }; const flagConfiguration = '{"flags":{}}'; - + const fetch = new GrpcFetch(cfgWithoutSelector, serviceMock); fetch .connect(dataCallback, reconnectCallback, jest.fn(), disconnectCallback) @@ -193,19 +193,19 @@ describe('grpc fetch', () => { try { // Verify syncFlags was called expect(serviceMock.syncFlags).toHaveBeenCalled(); - + // Check that both request and metadata were passed const callArgs = (serviceMock.syncFlags as jest.Mock).mock.calls[0]; expect(callArgs).toHaveLength(2); - + // Verify the request contains empty selector expect(callArgs[0].selector).toBe(''); - + // Verify the metadata does not contain flagd-selector header const metadata = callArgs[1] as Metadata; expect(metadata).toBeDefined(); expect(metadata.get('flagd-selector')).toEqual([]); - + done(); } catch (err) { done(err); diff --git a/libs/providers/flagd/src/lib/service/in-process/grpc/grpc-fetch.ts b/libs/providers/flagd/src/lib/service/in-process/grpc/grpc-fetch.ts index 8763f7295..ced620a8d 100644 --- a/libs/providers/flagd/src/lib/service/in-process/grpc/grpc-fetch.ts +++ b/libs/providers/flagd/src/lib/service/in-process/grpc/grpc-fetch.ts @@ -52,7 +52,7 @@ export class GrpcFetch implements DataFetch { // 1. The request field (deprecated, for older flagd versions) // 2. The gRPC metadata header 'flagd-selector' (new standard) this._request = { providerId: '', selector: selector ? selector : '' }; - + // Create metadata with the flagd-selector header this._metadata = new Metadata(); if (selector) { From 323bb3331c7fe0e34467895c0c528f9dc3d30c76 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Nov 2025 12:25:46 +0000 Subject: [PATCH 4/4] Refactor tests to use async/await instead of done callback - Remove done callback pattern from selector tests - Use async/await for cleaner async test handling - Simplifies error handling and improves readability - Follows Jest best practices for async tests Co-authored-by: aepfli <9987394+aepfli@users.noreply.github.com> --- .../in-process/grpc/grpc-fetch.spec.ts | 82 +++++++------------ 1 file changed, 30 insertions(+), 52 deletions(-) diff --git a/libs/providers/flagd/src/lib/service/in-process/grpc/grpc-fetch.spec.ts b/libs/providers/flagd/src/lib/service/in-process/grpc/grpc-fetch.spec.ts index 513870790..d32326aba 100644 --- a/libs/providers/flagd/src/lib/service/in-process/grpc/grpc-fetch.spec.ts +++ b/libs/providers/flagd/src/lib/service/in-process/grpc/grpc-fetch.spec.ts @@ -145,76 +145,54 @@ describe('grpc fetch', () => { onErrorCallback(new Error('Some connection error')); }); - it('should send selector via flagd-selector metadata header', (done) => { + it('should send selector via flagd-selector metadata header', async () => { const selector = 'app=weather'; const cfgWithSelector: Config = { ...cfg, selector }; const flagConfiguration = '{"flags":{}}'; const fetch = new GrpcFetch(cfgWithSelector, serviceMock); - fetch - .connect(dataCallback, reconnectCallback, jest.fn(), disconnectCallback) - .then(() => { - try { - // Verify syncFlags was called - expect(serviceMock.syncFlags).toHaveBeenCalled(); + const connectPromise = fetch.connect(dataCallback, reconnectCallback, jest.fn(), disconnectCallback); + onDataCallback({ flagConfiguration }); + await connectPromise; - // Check that both request and metadata were passed - const callArgs = (serviceMock.syncFlags as jest.Mock).mock.calls[0]; - expect(callArgs).toHaveLength(2); + // Verify syncFlags was called + expect(serviceMock.syncFlags).toHaveBeenCalled(); - // Verify the request contains selector (for backward compatibility) - expect(callArgs[0].selector).toBe(selector); + // Check that both request and metadata were passed + const callArgs = (serviceMock.syncFlags as jest.Mock).mock.calls[0]; + expect(callArgs).toHaveLength(2); - // Verify the metadata contains flagd-selector header - const metadata = callArgs[1] as Metadata; - expect(metadata).toBeDefined(); - expect(metadata.get('flagd-selector')).toEqual([selector]); + // Verify the request contains selector (for backward compatibility) + expect(callArgs[0].selector).toBe(selector); - done(); - } catch (err) { - done(err); - } - }) - .catch((err) => { - done(err); - }); - - onDataCallback({ flagConfiguration }); + // Verify the metadata contains flagd-selector header + const metadata = callArgs[1] as Metadata; + expect(metadata).toBeDefined(); + expect(metadata.get('flagd-selector')).toEqual([selector]); }); - it('should handle empty selector gracefully', (done) => { + it('should handle empty selector gracefully', async () => { const cfgWithoutSelector: Config = { ...cfg, selector: '' }; const flagConfiguration = '{"flags":{}}'; const fetch = new GrpcFetch(cfgWithoutSelector, serviceMock); - fetch - .connect(dataCallback, reconnectCallback, jest.fn(), disconnectCallback) - .then(() => { - try { - // Verify syncFlags was called - expect(serviceMock.syncFlags).toHaveBeenCalled(); + const connectPromise = fetch.connect(dataCallback, reconnectCallback, jest.fn(), disconnectCallback); + onDataCallback({ flagConfiguration }); + await connectPromise; - // Check that both request and metadata were passed - const callArgs = (serviceMock.syncFlags as jest.Mock).mock.calls[0]; - expect(callArgs).toHaveLength(2); + // Verify syncFlags was called + expect(serviceMock.syncFlags).toHaveBeenCalled(); - // Verify the request contains empty selector - expect(callArgs[0].selector).toBe(''); + // Check that both request and metadata were passed + const callArgs = (serviceMock.syncFlags as jest.Mock).mock.calls[0]; + expect(callArgs).toHaveLength(2); - // Verify the metadata does not contain flagd-selector header - const metadata = callArgs[1] as Metadata; - expect(metadata).toBeDefined(); - expect(metadata.get('flagd-selector')).toEqual([]); + // Verify the request contains empty selector + expect(callArgs[0].selector).toBe(''); - done(); - } catch (err) { - done(err); - } - }) - .catch((err) => { - done(err); - }); - - onDataCallback({ flagConfiguration }); + // Verify the metadata does not contain flagd-selector header + const metadata = callArgs[1] as Metadata; + expect(metadata).toBeDefined(); + expect(metadata.get('flagd-selector')).toEqual([]); }); });