-
Notifications
You must be signed in to change notification settings - Fork 36
/
Copy pathopen-feature.ts
412 lines (381 loc) · 17.3 KB
/
open-feature.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
import {
ClientProviderStatus,
EvaluationContext,
GenericEventEmitter,
ManageContext,
OpenFeatureCommonAPI,
ProviderWrapper,
objectOrUndefined,
stringOrUndefined,
} from '@openfeature/core';
import { Client } from './client';
import { OpenFeatureEventEmitter, ProviderEvents } from './events';
import { Hook } from './hooks';
import { NOOP_PROVIDER, Provider, ProviderStatus } from './provider';
import { OpenFeatureClient } from './client/open-feature-client';
// use a symbol as a key for the global singleton
const GLOBAL_OPENFEATURE_API_KEY = Symbol.for('@openfeature/web-sdk/api');
type OpenFeatureGlobal = {
[GLOBAL_OPENFEATURE_API_KEY]?: OpenFeatureAPI;
};
type DomainRecord = {
domain?: string;
wrapper: ProviderWrapper<Provider, ClientProviderStatus>;
};
const _globalThis = globalThis as OpenFeatureGlobal;
export class OpenFeatureAPI
extends OpenFeatureCommonAPI<ClientProviderStatus, Provider, Hook>
implements ManageContext<Promise<void>>
{
protected _statusEnumType: typeof ProviderStatus = ProviderStatus;
protected _apiEmitter: GenericEventEmitter<ProviderEvents> = new OpenFeatureEventEmitter();
protected _defaultProvider: ProviderWrapper<Provider, ClientProviderStatus> = new ProviderWrapper(
NOOP_PROVIDER,
ProviderStatus.NOT_READY,
this._statusEnumType,
);
protected _domainScopedProviders: Map<string, ProviderWrapper<Provider, ClientProviderStatus>> = new Map();
protected _createEventEmitter = () => new OpenFeatureEventEmitter();
private constructor() {
super('client');
}
/**
* Gets a singleton instance of the OpenFeature API.
* @ignore
* @returns {OpenFeatureAPI} OpenFeature API
*/
static getInstance(): OpenFeatureAPI {
const globalApi = _globalThis[GLOBAL_OPENFEATURE_API_KEY];
if (globalApi) {
return globalApi;
}
const instance = new OpenFeatureAPI();
_globalThis[GLOBAL_OPENFEATURE_API_KEY] = instance;
return instance;
}
private getProviderStatus(domain?: string): ProviderStatus {
if (!domain) {
return this._defaultProvider.status;
}
return this._domainScopedProviders.get(domain)?.status ?? this._defaultProvider.status;
}
/**
* Sets the default provider for flag evaluations and returns a promise that resolves when the provider is ready.
* This provider will be used by domainless clients and clients associated with domains to which no provider is bound.
* Setting a provider supersedes the current provider used in new and existing unbound clients.
* @param {Provider} provider The provider responsible for flag evaluations.
* @returns {Promise<void>}
* @throws Uncaught exceptions thrown by the provider during initialization.
*/
setProviderAndWait(provider: Provider): Promise<void>;
/**
* Sets the default provider for flag evaluations and returns a promise that resolves when the provider is ready.
* This provider will be used by domainless clients and clients associated with domains to which no provider is bound.
* Setting a provider supersedes the current provider used in new and existing unbound clients.
* @param {Provider} provider The provider responsible for flag evaluations.
* @param {EvaluationContext} context The evaluation context to use for flag evaluations.
* @returns {Promise<void>}
* @throws Uncaught exceptions thrown by the provider during initialization.
*/
setProviderAndWait(provider: Provider, context: EvaluationContext): Promise<void>;
/**
* Sets the provider that OpenFeature will use for flag evaluations on clients bound to the same domain.
* A promise is returned that resolves when the provider is ready.
* Setting a provider supersedes the current provider used in new and existing clients bound to the same domain.
* @param {string} domain The name to identify the client
* @param {Provider} provider The provider responsible for flag evaluations.
* @returns {Promise<void>}
* @throws Uncaught exceptions thrown by the provider during initialization.
*/
setProviderAndWait(domain: string, provider: Provider): Promise<void>;
/**
* Sets the provider that OpenFeature will use for flag evaluations on clients bound to the same domain.
* A promise is returned that resolves when the provider is ready.
* Setting a provider supersedes the current provider used in new and existing clients bound to the same domain.
* @param {string} domain The name to identify the client
* @param {Provider} provider The provider responsible for flag evaluations.
* @param {EvaluationContext} context The evaluation context to use for flag evaluations.
* @returns {Promise<void>}
* @throws Uncaught exceptions thrown by the provider during initialization.
*/
setProviderAndWait(domain: string, provider: Provider, context: EvaluationContext): Promise<void>;
async setProviderAndWait(
clientOrProvider?: string | Provider,
providerContextOrUndefined?: Provider | EvaluationContext,
contextOrUndefined?: EvaluationContext,
): Promise<void> {
const domain = stringOrUndefined(clientOrProvider);
const provider = domain
? objectOrUndefined<Provider>(providerContextOrUndefined)
: objectOrUndefined<Provider>(clientOrProvider);
const context = domain
? objectOrUndefined<EvaluationContext>(contextOrUndefined)
: objectOrUndefined<EvaluationContext>(providerContextOrUndefined);
if (context) {
// synonymously setting context prior to provider initialization.
// No context change event will be emitted.
if (domain) {
this._domainScopedContext.set(domain, context);
} else {
this._context = context;
}
}
await this.setAwaitableProvider(domain, provider);
}
/**
* Sets the default provider for flag evaluations.
* This provider will be used by domainless clients and clients associated with domains to which no provider is bound.
* Setting a provider supersedes the current provider used in new and existing unbound clients.
* @param {Provider} provider The provider responsible for flag evaluations.
* @returns {this} OpenFeature API
*/
setProvider(provider: Provider): this;
/**
* Sets the default provider and evaluation context for flag evaluations.
* This provider will be used by domainless clients and clients associated with domains to which no provider is bound.
* Setting a provider supersedes the current provider used in new and existing unbound clients.
* @param {Provider} provider The provider responsible for flag evaluations.
* @param context {EvaluationContext} The evaluation context to use for flag evaluations.
* @returns {this} OpenFeature API
*/
setProvider(provider: Provider, context: EvaluationContext): this;
/**
* Sets the provider for flag evaluations of providers with the given name.
* Setting a provider supersedes the current provider used in new and existing clients bound to the same domain.
* @param {string} domain The name to identify the client
* @param {Provider} provider The provider responsible for flag evaluations.
* @returns {this} OpenFeature API
*/
setProvider(domain: string, provider: Provider): this;
/**
* Sets the provider and evaluation context flag evaluations of providers with the given name.
* Setting a provider supersedes the current provider used in new and existing clients bound to the same domain.
* @param {string} domain The name to identify the client
* @param {Provider} provider The provider responsible for flag evaluations.
* @param context {EvaluationContext} The evaluation context to use for flag evaluations.
* @returns {this} OpenFeature API
*/
setProvider(domain: string, provider: Provider, context: EvaluationContext): this;
setProvider(
domainOrProvider?: string | Provider,
providerContextOrUndefined?: Provider | EvaluationContext,
contextOrUndefined?: EvaluationContext,
): this {
const domain = stringOrUndefined(domainOrProvider);
const provider = domain
? objectOrUndefined<Provider>(providerContextOrUndefined)
: objectOrUndefined<Provider>(domainOrProvider);
const context = domain
? objectOrUndefined<EvaluationContext>(contextOrUndefined)
: objectOrUndefined<EvaluationContext>(providerContextOrUndefined);
if (context) {
// synonymously setting context prior to provider initialization.
// No context change event will be emitted.
if (domain) {
this._domainScopedContext.set(domain, context);
} else {
this._context = context;
}
}
const maybePromise = this.setAwaitableProvider(domain, provider);
// The setProvider method doesn't return a promise so we need to catch and
// log any errors that occur during provider initialization to avoid having
// an unhandled promise rejection.
Promise.resolve(maybePromise).catch((err) => {
this._logger.error('Error during provider initialization:', err);
});
return this;
}
/**
* Sets the evaluation context globally.
* This will be used by all providers that have not bound to a domain.
* @param {EvaluationContext} context Evaluation context
* @example
* await OpenFeature.setContext({ region: "us" });
*/
async setContext(context: EvaluationContext): Promise<void>;
/**
* Sets the evaluation context for a specific provider.
* This will only affect providers bound to a domain.
* @param {string} domain An identifier which logically binds clients with providers
* @param {EvaluationContext} context Evaluation context
* @example
* await OpenFeature.setContext("test", { scope: "provider" });
* OpenFeature.setProvider(new MyProvider()) // Uses the default context
* OpenFeature.setProvider("test", new MyProvider()) // Uses context: { scope: "provider" }
*/
async setContext(domain: string, context: EvaluationContext): Promise<void>;
async setContext<T extends EvaluationContext>(domainOrContext: T | string, contextOrUndefined?: T): Promise<void> {
const domain = stringOrUndefined(domainOrContext);
const context = objectOrUndefined<T>(domainOrContext) ?? objectOrUndefined(contextOrUndefined) ?? {};
if (domain) {
const wrapper = this._domainScopedProviders.get(domain);
if (wrapper) {
const oldContext = this.getContext(domain);
this._domainScopedContext.set(domain, context);
await this.runProviderContextChangeHandler(domain, wrapper, oldContext, context);
} else {
this._domainScopedContext.set(domain, context);
}
} else {
const oldContext = this._context;
this._context = context;
// collect all providers that are using the default context (not bound to a domain)
const unboundProviders: DomainRecord[] = Array.from(this._domainScopedProviders.entries())
.filter(([domain]) => !this._domainScopedContext.has(domain))
.reduce<DomainRecord[]>((acc, [domain, wrapper]) => {
acc.push({ domain, wrapper });
return acc;
}, []);
const allDomainRecords: DomainRecord[] = [
// add in the default (no domain)
{ domain: undefined, wrapper: this._defaultProvider },
...unboundProviders,
];
await Promise.all(
allDomainRecords.map((dm) => this.runProviderContextChangeHandler(dm.domain, dm.wrapper, oldContext, context)),
);
}
}
/**
* Access the global evaluation context.
* @returns {EvaluationContext} Evaluation context
*/
getContext(): EvaluationContext;
/**
* Access the evaluation context for a specific named client.
* The global evaluation context is returned if a matching named client is not found.
* @param {string} domain An identifier which logically binds clients with providers
* @returns {EvaluationContext} Evaluation context
*/
getContext(domain?: string | undefined): EvaluationContext;
getContext(domainOrUndefined?: string): EvaluationContext {
const domain = stringOrUndefined(domainOrUndefined);
if (domain) {
const context = this._domainScopedContext.get(domain);
if (context) {
return context;
} else {
this._logger.debug(`Unable to find context for '${domain}'.`);
}
}
return this._context;
}
/**
* Resets the global evaluation context to an empty object.
*/
clearContext(): Promise<void>;
/**
* Removes the evaluation context for a specific named client.
* @param {string} domain An identifier which logically binds clients with providers
*/
clearContext(domain: string): Promise<void>;
async clearContext(domainOrUndefined?: string): Promise<void> {
const domain = stringOrUndefined(domainOrUndefined);
if (domain) {
const wrapper = this._domainScopedProviders.get(domain);
if (wrapper) {
const oldContext = this.getContext(domain);
this._domainScopedContext.delete(domain);
const newContext = this.getContext();
await this.runProviderContextChangeHandler(domain, wrapper, oldContext, newContext);
} else {
this._domainScopedContext.delete(domain);
}
} else {
return this.setContext({});
}
}
/**
* Resets the global evaluation context and removes the evaluation context for
* all domains.
*/
async clearContexts(): Promise<void> {
// Default context must be cleared first to avoid calling the onContextChange
// handler multiple times for clients bound to a domain.
await this.clearContext();
// Use allSettled so a promise rejection doesn't affect others
await Promise.allSettled(Array.from(this._domainScopedProviders.keys()).map((domain) => this.clearContext(domain)));
}
/**
* A factory function for creating new named OpenFeature clients. Clients can contain
* their own state (e.g. logger, hook, context). Multiple clients can be used
* to segment feature flag configuration.
*
* If there is already a provider bound to this name via {@link this.setProvider setProvider}, this provider will be used.
* Otherwise, the default provider is used until a provider is assigned to that name.
* @param {string} domain An identifier which logically binds clients with providers
* @param {string} version The version of the client (only used for metadata)
* @returns {Client} OpenFeature Client
*/
getClient(domain?: string, version?: string): Client {
return new OpenFeatureClient(
// functions are passed here to make sure that these values are always up to date,
// and so we don't have to make these public properties on the API class.
() => this.getProviderForClient(domain),
() => this.getProviderStatus(domain),
() => this.buildAndCacheEventEmitterForClient(domain),
() => this._logger,
{ domain, version },
);
}
/**
* Clears all registered providers and resets the default provider.
* @returns {Promise<void>}
*/
async clearProviders(): Promise<void> {
await super.clearProvidersAndSetDefault(NOOP_PROVIDER);
this._domainScopedContext.clear();
}
private async runProviderContextChangeHandler(
domain: string | undefined,
wrapper: ProviderWrapper<Provider, ClientProviderStatus>,
oldContext: EvaluationContext,
newContext: EvaluationContext,
): Promise<void> {
// this should always be set according to the typings, but let's be defensive considering JS
const providerName = wrapper.provider?.metadata?.name || 'unnamed-provider';
try {
if (typeof wrapper.provider.onContextChange === 'function') {
const maybePromise = wrapper.provider.onContextChange(oldContext, newContext);
// only reconcile if the onContextChange method returns a promise
if (typeof maybePromise?.then === 'function') {
wrapper.incrementPendingContextChanges();
wrapper.status = this._statusEnumType.RECONCILING;
this.getAssociatedEventEmitters(domain).forEach((emitter) => {
emitter?.emit(ProviderEvents.Reconciling, { domain, providerName });
});
this._apiEmitter?.emit(ProviderEvents.Reconciling, { domain, providerName });
await maybePromise;
wrapper.decrementPendingContextChanges();
}
}
// only run the event handlers, and update the state if the onContextChange method succeeded
wrapper.status = this._statusEnumType.READY;
if (wrapper.allContextChangesSettled) {
this.getAssociatedEventEmitters(domain).forEach((emitter) => {
emitter?.emit(ProviderEvents.ContextChanged, { clientName: domain, domain, providerName });
});
this._apiEmitter?.emit(ProviderEvents.ContextChanged, { clientName: domain, domain, providerName });
}
} catch (err) {
// run error handlers instead
wrapper.decrementPendingContextChanges();
wrapper.status = this._statusEnumType.ERROR;
if (wrapper.allContextChangesSettled) {
const error = err as Error | undefined;
const message = `Error running ${providerName}'s context change handler: ${error?.message}`;
this._logger?.error(`${message}`, err);
this.getAssociatedEventEmitters(domain).forEach((emitter) => {
emitter?.emit(ProviderEvents.Error, { clientName: domain, domain, providerName, message });
});
this._apiEmitter?.emit(ProviderEvents.Error, { clientName: domain, domain, providerName, message });
}
}
}
}
/**
* A singleton instance of the OpenFeature API.
* @returns {OpenFeatureAPI} OpenFeature API
*/
export const OpenFeature = OpenFeatureAPI.getInstance();