From 9cf672e7ce463466e62a98c4325cfa521d9cd3e3 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 24 Feb 2026 09:00:34 -0800 Subject: [PATCH 1/2] feat(contract-tests): add flag change listener support for Node server SDK Implement contract test support for flag change listeners in the Node server SDK test service: - Add 'flag-change-listeners' capability to the service - Handle registerFlagChangeListener, registerFlagValueChangeListener, and unregisterListener commands - Use SDK 'update' and 'update:${key}' events for config and value changes - Post listener callbacks to the test harness via HTTP - Unregister listeners on client close --- .../server-node/contract-tests/src/index.ts | 1 + .../contract-tests/src/sdkClientEntity.ts | 89 +++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/packages/sdk/server-node/contract-tests/src/index.ts b/packages/sdk/server-node/contract-tests/src/index.ts index 882ff549d5..f366bf9ca4 100644 --- a/packages/sdk/server-node/contract-tests/src/index.ts +++ b/packages/sdk/server-node/contract-tests/src/index.ts @@ -42,6 +42,7 @@ app.get('/', (req: Request, res: Response) => { 'client-prereq-events', 'event-gzip', 'optional-event-gzip', + 'flag-change-listeners', ], }); }); diff --git a/packages/sdk/server-node/contract-tests/src/sdkClientEntity.ts b/packages/sdk/server-node/contract-tests/src/sdkClientEntity.ts index 2c061f05f3..dc175736e8 100644 --- a/packages/sdk/server-node/contract-tests/src/sdkClientEntity.ts +++ b/packages/sdk/server-node/contract-tests/src/sdkClientEntity.ts @@ -140,6 +140,21 @@ interface CommandParams { newEndpoint: string; oldEndpoint: string; }; + registerFlagChangeListener?: { + listenerId: string; + flagKey: string; + callbackUri: string; + }; + registerFlagValueChangeListener?: { + listenerId: string; + flagKey: string; + context: LDContext; + defaultValue: LDFlagValue; + callbackUri: string; + }; + unregisterListener?: { + listenerId: string; + }; } export function makeSdkConfig(options: SdkConfigOptions, tag: string): LDOptions { @@ -316,9 +331,15 @@ export interface SdkClientEntity { doCommand: (params: CommandParams) => Promise; } +interface ListenerEntry { + eventName: string; + handler: (...args: any[]) => void; +} + export async function newSdkClientEntity(options: any): Promise { const c: any = {}; const log = Log(options.tag); + const listeners = new Map(); log.info(`Creating client with configuration: ${JSON.stringify(options.configuration)}`); const timeout = @@ -341,6 +362,11 @@ export async function newSdkClientEntity(options: any): Promise } c.close = () => { + // Unregister all listeners before closing to avoid firing callbacks after shutdown. + listeners.forEach((entry) => { + client.off(entry.eventName, entry.handler); + }); + listeners.clear(); client.close(); log.info('Test ended'); }; @@ -514,6 +540,69 @@ export async function newSdkClientEntity(options: any): Promise } } + case 'registerFlagChangeListener': { + const p = params.registerFlagChangeListener!; + // 'update:key' fires for a specific flag; 'update' (no key) fires for any flag change. + const eventName = p.flagKey ? `update:${p.flagKey}` : 'update'; + + const handler = (eventParams: { key: string }) => { + got + .post(p.callbackUri, { + json: { + listenerId: p.listenerId, + flagKey: eventParams.key, + }, + }) + .catch(() => {}); + }; + + listeners.set(p.listenerId, { eventName, handler }); + client.on(eventName, handler); + return undefined; + } + + case 'registerFlagValueChangeListener': { + const p = params.registerFlagValueChangeListener!; + const eventName = `update:${p.flagKey}`; + + // Snapshot the current evaluated value so we can detect actual value changes. + // On each SDK update event, re-evaluate and only notify the harness if the + // evaluated value differs (using JSON comparison for deep equality). + let oldValue = await client.variation(p.flagKey, p.context, p.defaultValue); + + const handler = async () => { + const newValue = await client.variation(p.flagKey, p.context, p.defaultValue); + if (JSON.stringify(newValue) !== JSON.stringify(oldValue)) { + const previousValue = oldValue; + oldValue = newValue; + got + .post(p.callbackUri, { + json: { + listenerId: p.listenerId, + flagKey: p.flagKey, + oldValue: previousValue, + newValue, + }, + }) + .catch(() => {}); + } + }; + + listeners.set(p.listenerId, { eventName, handler }); + client.on(eventName, handler); + return undefined; + } + + case 'unregisterListener': { + const p = params.unregisterListener!; + const entry = listeners.get(p.listenerId); + if (entry) { + client.off(entry.eventName, entry.handler); + listeners.delete(p.listenerId); + } + return undefined; + } + default: throw badCommandError; } From 69573afc980d15b93dbb7ac80fdace124fc651e1 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Thu, 26 Feb 2026 16:48:13 -0800 Subject: [PATCH 2/2] Responding to code review comments --- .../contract-tests/src/sdkClientEntity.ts | 47 ++----------------- 1 file changed, 5 insertions(+), 42 deletions(-) diff --git a/packages/sdk/server-node/contract-tests/src/sdkClientEntity.ts b/packages/sdk/server-node/contract-tests/src/sdkClientEntity.ts index dc175736e8..f1fcfabe2b 100644 --- a/packages/sdk/server-node/contract-tests/src/sdkClientEntity.ts +++ b/packages/sdk/server-node/contract-tests/src/sdkClientEntity.ts @@ -142,14 +142,6 @@ interface CommandParams { }; registerFlagChangeListener?: { listenerId: string; - flagKey: string; - callbackUri: string; - }; - registerFlagValueChangeListener?: { - listenerId: string; - flagKey: string; - context: LDContext; - defaultValue: LDFlagValue; callbackUri: string; }; unregisterListener?: { @@ -542,8 +534,7 @@ export async function newSdkClientEntity(options: any): Promise case 'registerFlagChangeListener': { const p = params.registerFlagChangeListener!; - // 'update:key' fires for a specific flag; 'update' (no key) fires for any flag change. - const eventName = p.flagKey ? `update:${p.flagKey}` : 'update'; + const eventName = 'update'; const handler = (eventParams: { key: string }) => { got @@ -556,38 +547,10 @@ export async function newSdkClientEntity(options: any): Promise .catch(() => {}); }; - listeners.set(p.listenerId, { eventName, handler }); - client.on(eventName, handler); - return undefined; - } - - case 'registerFlagValueChangeListener': { - const p = params.registerFlagValueChangeListener!; - const eventName = `update:${p.flagKey}`; - - // Snapshot the current evaluated value so we can detect actual value changes. - // On each SDK update event, re-evaluate and only notify the harness if the - // evaluated value differs (using JSON comparison for deep equality). - let oldValue = await client.variation(p.flagKey, p.context, p.defaultValue); - - const handler = async () => { - const newValue = await client.variation(p.flagKey, p.context, p.defaultValue); - if (JSON.stringify(newValue) !== JSON.stringify(oldValue)) { - const previousValue = oldValue; - oldValue = newValue; - got - .post(p.callbackUri, { - json: { - listenerId: p.listenerId, - flagKey: p.flagKey, - oldValue: previousValue, - newValue, - }, - }) - .catch(() => {}); - } - }; - + const existing = listeners.get(p.listenerId); + if (existing) { + client.off(existing.eventName, existing.handler); + } listeners.set(p.listenerId, { eventName, handler }); client.on(eventName, handler); return undefined;