Skip to content

Commit 2519ad1

Browse files
feat: add context change listeners for flag subscriptions
Signed-off-by: Jonathan Norris <[email protected]>
1 parent d21c041 commit 2519ad1

File tree

10 files changed

+657
-25
lines changed

10 files changed

+657
-25
lines changed

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/shared/src/events/generic-event-emitter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Logger, ManageLogger} from '../logger';
1+
import type { Logger, ManageLogger } from '../logger';
22
import { SafeLogger } from '../logger';
33
import type { ProviderEventEmitter } from './provider-event-emitter';
44
import type { EventContext, EventDetails, EventHandler } from './eventing';

packages/web/src/client/client.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import type { ClientMetadata, EvaluationLifeCycle, Eventing, ManageLogger } from '@openfeature/core';
2-
import type { Features } from '../evaluation';
2+
import type { Features, ContextChangeSubscriptions } from '../evaluation';
33
import type { ProviderStatus } from '../provider';
44
import type { ProviderEvents } from '../events';
55
import type { Tracking } from '../tracking';
66

77
export interface Client
88
extends EvaluationLifeCycle<Client>,
99
Features,
10+
ContextChangeSubscriptions,
1011
ManageLogger<Client>,
1112
Eventing<ProviderEvents>,
1213
Tracking {

packages/web/src/client/internal/open-feature-client.ts

Lines changed: 123 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ import {
2525
MapHookData,
2626
} from '@openfeature/core';
2727
import type { FlagEvaluationOptions } from '../../evaluation';
28-
import type { ProviderEvents } from '../../events';
28+
import { EvaluationDetailsWithSubscription } from '../../evaluation';
29+
import { ProviderEvents } from '../../events';
2930
import type { InternalEventEmitter } from '../../events/internal/internal-event-emitter';
3031
import type { Hook } from '../../hooks';
3132
import type { Provider } from '../../provider';
@@ -136,7 +137,13 @@ export class OpenFeatureClient implements Client {
136137
defaultValue: boolean,
137138
options?: FlagEvaluationOptions,
138139
): EvaluationDetails<boolean> {
139-
return this.evaluate<boolean>(flagKey, this._provider.resolveBooleanEvaluation, defaultValue, 'boolean', options);
140+
return this.evaluateWithSubscription<boolean>(
141+
flagKey,
142+
this._provider.resolveBooleanEvaluation,
143+
defaultValue,
144+
'boolean',
145+
options,
146+
);
140147
}
141148

142149
getStringValue<T extends string = string>(flagKey: string, defaultValue: T, options?: FlagEvaluationOptions): T {
@@ -148,7 +155,7 @@ export class OpenFeatureClient implements Client {
148155
defaultValue: T,
149156
options?: FlagEvaluationOptions,
150157
): EvaluationDetails<T> {
151-
return this.evaluate<T>(
158+
return this.evaluateWithSubscription<T>(
152159
flagKey,
153160
// this isolates providers from our restricted string generic argument.
154161
this._provider.resolveStringEvaluation as () => EvaluationDetails<T>,
@@ -167,7 +174,7 @@ export class OpenFeatureClient implements Client {
167174
defaultValue: T,
168175
options?: FlagEvaluationOptions,
169176
): EvaluationDetails<T> {
170-
return this.evaluate<T>(
177+
return this.evaluateWithSubscription<T>(
171178
flagKey,
172179
// this isolates providers from our restricted number generic argument.
173180
this._provider.resolveNumberEvaluation as () => EvaluationDetails<T>,
@@ -190,7 +197,107 @@ export class OpenFeatureClient implements Client {
190197
defaultValue: T,
191198
options?: FlagEvaluationOptions,
192199
): EvaluationDetails<T> {
193-
return this.evaluate<T>(flagKey, this._provider.resolveObjectEvaluation, defaultValue, 'object', options);
200+
return this.evaluateWithSubscription<T>(
201+
flagKey,
202+
this._provider.resolveObjectEvaluation,
203+
defaultValue,
204+
'object',
205+
options,
206+
);
207+
}
208+
209+
onBooleanContextChanged(
210+
flagKey: string,
211+
defaultValue: boolean,
212+
callback: (newDetails: EvaluationDetails<boolean>, oldDetails: EvaluationDetails<boolean>) => void,
213+
options?: FlagEvaluationOptions,
214+
): () => void {
215+
return this.subscribeToContextChanges(flagKey, defaultValue, 'boolean', callback, options);
216+
}
217+
218+
onStringContextChanged(
219+
flagKey: string,
220+
defaultValue: string,
221+
callback: (newDetails: EvaluationDetails<string>, oldDetails: EvaluationDetails<string>) => void,
222+
options?: FlagEvaluationOptions,
223+
): () => void {
224+
return this.subscribeToContextChanges(flagKey, defaultValue, 'string', callback, options);
225+
}
226+
227+
onNumberContextChanged(
228+
flagKey: string,
229+
defaultValue: number,
230+
callback: (newDetails: EvaluationDetails<number>, oldDetails: EvaluationDetails<number>) => void,
231+
options?: FlagEvaluationOptions,
232+
): () => void {
233+
return this.subscribeToContextChanges(flagKey, defaultValue, 'number', callback, options);
234+
}
235+
236+
onObjectContextChanged<T extends JsonValue = JsonValue>(
237+
flagKey: string,
238+
defaultValue: T,
239+
callback: (newDetails: EvaluationDetails<T>, oldDetails: EvaluationDetails<T>) => void,
240+
options?: FlagEvaluationOptions,
241+
): () => void {
242+
return this.subscribeToContextChanges(flagKey, defaultValue, 'object', callback, options);
243+
}
244+
245+
private subscribeToContextChanges<T extends FlagValue>(
246+
flagKey: string,
247+
defaultValue: T,
248+
flagType: FlagValueType,
249+
callback: (newDetails: EvaluationDetails<T>, oldDetails: EvaluationDetails<T>) => void,
250+
options?: FlagEvaluationOptions,
251+
): () => void {
252+
let currentDetails: EvaluationDetails<T>;
253+
254+
switch (flagType) {
255+
case 'boolean':
256+
currentDetails = this.getBooleanDetails(flagKey, defaultValue as boolean, options) as EvaluationDetails<T>;
257+
break;
258+
case 'string':
259+
currentDetails = this.getStringDetails(flagKey, defaultValue as string, options) as EvaluationDetails<T>;
260+
break;
261+
case 'number':
262+
currentDetails = this.getNumberDetails(flagKey, defaultValue as number, options) as EvaluationDetails<T>;
263+
break;
264+
case 'object':
265+
currentDetails = this.getObjectDetails(flagKey, defaultValue as JsonValue, options) as EvaluationDetails<T>;
266+
break;
267+
default:
268+
throw new Error(`Unsupported flag type: ${flagType}`);
269+
}
270+
271+
const handler = () => {
272+
const oldDetails = { ...currentDetails };
273+
let newDetails: EvaluationDetails<T>;
274+
275+
switch (flagType) {
276+
case 'boolean':
277+
newDetails = this.getBooleanDetails(flagKey, defaultValue as boolean, options) as EvaluationDetails<T>;
278+
break;
279+
case 'string':
280+
newDetails = this.getStringDetails(flagKey, defaultValue as string, options) as EvaluationDetails<T>;
281+
break;
282+
case 'number':
283+
newDetails = this.getNumberDetails(flagKey, defaultValue as number, options) as EvaluationDetails<T>;
284+
break;
285+
case 'object':
286+
newDetails = this.getObjectDetails(flagKey, defaultValue as JsonValue, options) as EvaluationDetails<T>;
287+
break;
288+
default:
289+
return;
290+
}
291+
292+
currentDetails = newDetails;
293+
callback(newDetails, oldDetails);
294+
};
295+
296+
this.addHandler(ProviderEvents.ContextChanged, handler, {});
297+
298+
return () => {
299+
this.removeHandler(ProviderEvents.ContextChanged, handler);
300+
};
194301
}
195302

196303
track(occurrenceKey: string, occurrenceDetails: TrackingEventDetails = {}): void {
@@ -211,6 +318,17 @@ export class OpenFeatureClient implements Client {
211318
}
212319
}
213320

321+
private evaluateWithSubscription<T extends FlagValue>(
322+
flagKey: string,
323+
resolver: (flagKey: string, defaultValue: T, context: EvaluationContext, logger: Logger) => ResolutionDetails<T>,
324+
defaultValue: T,
325+
flagType: FlagValueType,
326+
options: FlagEvaluationOptions = {},
327+
): EvaluationDetails<T> {
328+
const details = this.evaluate<T>(flagKey, resolver, defaultValue, flagType, options);
329+
return new EvaluationDetailsWithSubscription(this, flagKey, defaultValue, flagType, details, options);
330+
}
331+
214332
private evaluate<T extends FlagValue>(
215333
flagKey: string,
216334
resolver: (flagKey: string, defaultValue: T, context: EvaluationContext, logger: Logger) => ResolutionDetails<T>,
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import type { EvaluationDetails, ErrorCode, FlagValue, FlagValueType, JsonValue } from '@openfeature/core';
2+
import type { Client } from '../client';
3+
import { ProviderEvents } from '../events';
4+
import type { FlagEvaluationOptions } from './evaluation';
5+
6+
export class EvaluationDetailsWithSubscription<T extends FlagValue> implements EvaluationDetails<T> {
7+
private _details: EvaluationDetails<T>;
8+
private readonly _flagKey: string;
9+
private readonly _defaultValue: T;
10+
private readonly _flagType: FlagValueType;
11+
private readonly _options?: FlagEvaluationOptions;
12+
13+
constructor(
14+
private readonly client: Client,
15+
flagKey: string,
16+
defaultValue: T,
17+
flagType: FlagValueType,
18+
initialDetails: EvaluationDetails<T>,
19+
options?: FlagEvaluationOptions,
20+
) {
21+
this._details = initialDetails;
22+
this._flagKey = flagKey;
23+
this._defaultValue = defaultValue;
24+
this._flagType = flagType;
25+
this._options = options;
26+
}
27+
28+
get flagKey(): string {
29+
return this._details.flagKey;
30+
}
31+
32+
get value(): T {
33+
return this._details.value;
34+
}
35+
36+
get variant(): string | undefined {
37+
return this._details.variant;
38+
}
39+
40+
get flagMetadata(): Readonly<Record<string, string | number | boolean>> {
41+
return this._details.flagMetadata;
42+
}
43+
44+
get reason(): string | undefined {
45+
return this._details.reason;
46+
}
47+
48+
get errorCode(): ErrorCode | undefined {
49+
return this._details.errorCode;
50+
}
51+
52+
get errorMessage(): string | undefined {
53+
return this._details.errorMessage;
54+
}
55+
56+
onContextChanged(callback: (newDetails: EvaluationDetails<T>, oldDetails: EvaluationDetails<T>) => void): () => void {
57+
const handler = () => {
58+
const oldDetails = { ...this._details };
59+
let newDetails: EvaluationDetails<T>;
60+
61+
switch (this._flagType) {
62+
case 'boolean':
63+
newDetails = this.client.getBooleanDetails(
64+
this._flagKey,
65+
this._defaultValue as boolean,
66+
this._options,
67+
) as EvaluationDetails<T>;
68+
break;
69+
case 'string':
70+
newDetails = this.client.getStringDetails(
71+
this._flagKey,
72+
this._defaultValue as string,
73+
this._options,
74+
) as EvaluationDetails<T>;
75+
break;
76+
case 'number':
77+
newDetails = this.client.getNumberDetails(
78+
this._flagKey,
79+
this._defaultValue as number,
80+
this._options,
81+
) as EvaluationDetails<T>;
82+
break;
83+
case 'object':
84+
newDetails = this.client.getObjectDetails(
85+
this._flagKey,
86+
this._defaultValue as JsonValue,
87+
this._options,
88+
) as EvaluationDetails<T>;
89+
break;
90+
default:
91+
return;
92+
}
93+
94+
this._details = newDetails;
95+
callback(newDetails, oldDetails);
96+
};
97+
98+
this.client.addHandler(ProviderEvents.ContextChanged, handler);
99+
100+
return () => {
101+
this.client.removeHandler(ProviderEvents.ContextChanged, handler);
102+
};
103+
}
104+
}

0 commit comments

Comments
 (0)