js-sdk/packages/server/src/client/internal/open-feature-client.ts

382 lines
11 KiB
TypeScript

import {
ClientMetadata,
ErrorCode,
EvaluationContext,
EvaluationDetails,
EventHandler,
FlagValue,
FlagValueType,
HookContext,
JsonValue,
Logger,
OpenFeatureError,
ProviderFatalError,
ProviderNotReadyError,
ResolutionDetails,
SafeLogger,
StandardResolutionReasons,
instantiateErrorByErrorCode,
statusMatchesEvent,
} from '@openfeature/core';
import { FlagEvaluationOptions } from '../../evaluation';
import { ProviderEvents } from '../../events';
import { InternalEventEmitter } from '../../events/internal/internal-event-emitter';
import { Hook } from '../../hooks';
import { OpenFeature } from '../../open-feature';
import { Provider, ProviderStatus } from '../../provider';
import { Client } from './../client';
type OpenFeatureClientOptions = {
/**
* @deprecated Use `domain` instead.
*/
name?: string;
domain?: string;
version?: string;
};
/**
* This implementation of the {@link Client} is meant to only be instantiated by the SDK.
* It should not be used outside the SDK and so should not be exported.
* @internal
*/
export class OpenFeatureClient implements Client {
private _context: EvaluationContext;
private _hooks: Hook[] = [];
private _clientLogger?: Logger;
constructor(
// we always want the client to use the current provider,
// so pass a function to always access the currently registered one.
private readonly providerAccessor: () => Provider,
private readonly providerStatusAccessor: () => ProviderStatus,
private readonly emitterAccessor: () => InternalEventEmitter,
private readonly globalLogger: () => Logger,
private readonly options: OpenFeatureClientOptions,
context: EvaluationContext = {},
) {
this._context = context;
}
get metadata(): ClientMetadata {
return {
// Use domain if name is not provided
name: this.options.domain ?? this.options.name,
domain: this.options.domain ?? this.options.name,
version: this.options.version,
providerMetadata: this.providerAccessor().metadata,
};
}
get providerStatus(): ProviderStatus {
return this.providerStatusAccessor();
}
addHandler(eventType: ProviderEvents, handler: EventHandler): void {
this.emitterAccessor().addHandler(eventType, handler);
const shouldRunNow = statusMatchesEvent(eventType, this._providerStatus);
if (shouldRunNow) {
// run immediately, we're in the matching state
try {
handler({
clientName: this.metadata.name,
domain: this.metadata.domain,
providerName: this._provider.metadata.name,
});
} catch (err) {
this._logger?.error('Error running event handler:', err);
}
}
}
removeHandler(eventType: ProviderEvents, handler: EventHandler) {
this.emitterAccessor().removeHandler(eventType, handler);
}
getHandlers(eventType: ProviderEvents) {
return this.emitterAccessor().getHandlers(eventType);
}
setLogger(logger: Logger): OpenFeatureClient {
this._clientLogger = new SafeLogger(logger);
return this;
}
setContext(context: EvaluationContext): OpenFeatureClient {
this._context = context;
return this;
}
getContext(): EvaluationContext {
return this._context;
}
addHooks(...hooks: Hook[]): OpenFeatureClient {
this._hooks = [...this._hooks, ...hooks];
return this;
}
getHooks(): Hook[] {
return this._hooks;
}
clearHooks(): OpenFeatureClient {
this._hooks = [];
return this;
}
async getBooleanValue(
flagKey: string,
defaultValue: boolean,
context?: EvaluationContext,
options?: FlagEvaluationOptions,
): Promise<boolean> {
return (await this.getBooleanDetails(flagKey, defaultValue, context, options)).value;
}
getBooleanDetails(
flagKey: string,
defaultValue: boolean,
context?: EvaluationContext,
options?: FlagEvaluationOptions,
): Promise<EvaluationDetails<boolean>> {
return this.evaluate<boolean>(
flagKey,
this._provider.resolveBooleanEvaluation,
defaultValue,
'boolean',
context,
options,
);
}
async getStringValue<T extends string = string>(
flagKey: string,
defaultValue: T,
context?: EvaluationContext,
options?: FlagEvaluationOptions,
): Promise<T> {
return (await this.getStringDetails<T>(flagKey, defaultValue, context, options)).value;
}
getStringDetails<T extends string = string>(
flagKey: string,
defaultValue: T,
context?: EvaluationContext,
options?: FlagEvaluationOptions,
): Promise<EvaluationDetails<T>> {
return this.evaluate<T>(
flagKey,
// this isolates providers from our restricted string generic argument.
this._provider.resolveStringEvaluation as () => Promise<EvaluationDetails<T>>,
defaultValue,
'string',
context,
options,
);
}
async getNumberValue<T extends number = number>(
flagKey: string,
defaultValue: T,
context?: EvaluationContext,
options?: FlagEvaluationOptions,
): Promise<T> {
return (await this.getNumberDetails(flagKey, defaultValue, context, options)).value;
}
getNumberDetails<T extends number = number>(
flagKey: string,
defaultValue: T,
context?: EvaluationContext,
options?: FlagEvaluationOptions,
): Promise<EvaluationDetails<T>> {
return this.evaluate<T>(
flagKey,
// this isolates providers from our restricted number generic argument.
this._provider.resolveNumberEvaluation as () => Promise<EvaluationDetails<T>>,
defaultValue,
'number',
context,
options,
);
}
async getObjectValue<T extends JsonValue = JsonValue>(
flagKey: string,
defaultValue: T,
context?: EvaluationContext,
options?: FlagEvaluationOptions,
): Promise<T> {
return (await this.getObjectDetails(flagKey, defaultValue, context, options)).value;
}
getObjectDetails<T extends JsonValue = JsonValue>(
flagKey: string,
defaultValue: T,
context?: EvaluationContext,
options?: FlagEvaluationOptions,
): Promise<EvaluationDetails<T>> {
return this.evaluate<T>(flagKey, this._provider.resolveObjectEvaluation, defaultValue, 'object', context, options);
}
private async evaluate<T extends FlagValue>(
flagKey: string,
resolver: (
flagKey: string,
defaultValue: T,
context: EvaluationContext,
logger: Logger,
) => Promise<ResolutionDetails<T>>,
defaultValue: T,
flagType: FlagValueType,
invocationContext: EvaluationContext = {},
options: FlagEvaluationOptions = {},
): Promise<EvaluationDetails<T>> {
// merge global, client, and evaluation context
const allHooks = [
...OpenFeature.getHooks(),
...this.getHooks(),
...(options.hooks || []),
...(this._provider.hooks || []),
];
const allHooksReversed = [...allHooks].reverse();
// merge global and client contexts
const mergedContext = {
...OpenFeature.getContext(),
...OpenFeature.getTransactionContext(),
...this._context,
...invocationContext,
};
// this reference cannot change during the course of evaluation
// it may be used as a key in WeakMaps
const hookContext: Readonly<HookContext> = {
flagKey,
defaultValue,
flagValueType: flagType,
clientMetadata: this.metadata,
providerMetadata: this._provider.metadata,
context: mergedContext,
logger: this._logger,
};
try {
const frozenContext = await this.beforeHooks(allHooks, hookContext, options);
// short circuit evaluation entirely if provider is in a bad state
if (this.providerStatus === ProviderStatus.NOT_READY) {
throw new ProviderNotReadyError('provider has not yet initialized');
} else if (this.providerStatus === ProviderStatus.FATAL) {
throw new ProviderFatalError('provider is in an irrecoverable error state');
}
// run the referenced resolver, binding the provider.
const resolution = await resolver.call(this._provider, flagKey, defaultValue, frozenContext, this._logger);
const evaluationDetails = {
...resolution,
flagMetadata: Object.freeze(resolution.flagMetadata ?? {}),
flagKey,
};
if (evaluationDetails.errorCode) {
throw instantiateErrorByErrorCode(evaluationDetails.errorCode);
}
await this.afterHooks(allHooksReversed, hookContext, evaluationDetails, options);
return evaluationDetails;
} catch (err: unknown) {
const errorMessage: string = (err as Error)?.message;
const errorCode: ErrorCode = (err as OpenFeatureError)?.code || ErrorCode.GENERAL;
await this.errorHooks(allHooksReversed, hookContext, err, options);
return {
errorCode,
errorMessage,
value: defaultValue,
reason: StandardResolutionReasons.ERROR,
flagMetadata: Object.freeze({}),
flagKey,
};
} finally {
await this.finallyHooks(allHooksReversed, hookContext, options);
}
}
private async beforeHooks(hooks: Hook[], hookContext: HookContext, options: FlagEvaluationOptions) {
for (const hook of hooks) {
// freeze the hookContext
Object.freeze(hookContext);
// use Object.assign to avoid modification of frozen hookContext
Object.assign(hookContext.context, {
...hookContext.context,
...(await hook?.before?.(hookContext, Object.freeze(options.hookHints))),
});
}
// after before hooks, freeze the EvaluationContext.
return Object.freeze(hookContext.context);
}
private async afterHooks(
hooks: Hook[],
hookContext: HookContext,
evaluationDetails: EvaluationDetails<FlagValue>,
options: FlagEvaluationOptions,
) {
// run "after" hooks sequentially
for (const hook of hooks) {
await hook?.after?.(hookContext, evaluationDetails, options.hookHints);
}
}
private async errorHooks(hooks: Hook[], hookContext: HookContext, err: unknown, options: FlagEvaluationOptions) {
// run "error" hooks sequentially
for (const hook of hooks) {
try {
await hook?.error?.(hookContext, err, options.hookHints);
} catch (err) {
this._logger.error(`Unhandled error during 'error' hook: ${err}`);
if (err instanceof Error) {
this._logger.error(err.stack);
}
this._logger.error((err as Error)?.stack);
}
}
}
private async finallyHooks(hooks: Hook[], hookContext: HookContext, options: FlagEvaluationOptions) {
// run "finally" hooks sequentially
for (const hook of hooks) {
try {
await hook?.finally?.(hookContext, options.hookHints);
} catch (err) {
this._logger.error(`Unhandled error during 'finally' hook: ${err}`);
if (err instanceof Error) {
this._logger.error(err.stack);
}
this._logger.error((err as Error)?.stack);
}
}
}
private get _provider(): Provider {
return this.providerAccessor();
}
private get _providerStatus(): ProviderStatus {
return this.providerStatusAccessor();
}
private get _logger() {
return this._clientLogger || this.globalLogger();
}
}