Skip to main content
Glama

Bucket Feature Flags MCP Server

Official
by reflagcom
client.ts46.7 kB
import fs from "fs"; import { EvaluationResult, flattenJSON, newEvaluator, } from "@reflag/flag-evaluation"; import BatchBuffer from "./batch-buffer"; import { API_BASE_URL, API_TIMEOUT_MS, FLAG_EVENT_RATE_LIMITER_WINDOW_SIZE_MS, FLAGS_REFETCH_MS, loadConfig, REFLAG_LOG_PREFIX, SDK_VERSION, SDK_VERSION_HEADER_NAME, } from "./config"; import fetchClient, { withRetry } from "./fetch-http-client"; import { subscribe as triggerOnExit } from "./flusher"; import inRequestCache from "./inRequestCache"; import periodicallyUpdatingCache from "./periodicallyUpdatingCache"; import { newRateLimiter } from "./rate-limiter"; import type { BootstrappedFlags, CachedFlagDefinition, CacheStrategy, EvaluatedFlagsAPIResponse, FlagDefinition, FlagOverrides, FlagOverridesFn, IdType, RawFlag, RawFlags, TypedFlagKey, } from "./types"; import { Attributes, Cache, ClientOptions, Context, ContextWithTracking, FlagEvent, FlagsAPIResponse, HttpClient, Logger, TrackingMeta, TrackOptions, TypedFlags, } from "./types"; import { applyLogLevel, decorateLogger, hashObject, idOk, isObject, mergeSkipUndefined, ok, once, } from "./utils"; const reflagConfigDefaultFile = "reflag.config.json"; type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>; type BulkEvent = | { type: "company"; companyId: IdType; userId?: IdType; attributes?: Attributes; context?: TrackingMeta; } | { type: "user"; userId: IdType; attributes?: Attributes; context?: TrackingMeta; } | { type: "feature-flag-event"; action: "check" | "check-config"; key: string; targetingVersion?: number; evalResult: | boolean | { key: string; payload: any } | { key: undefined; payload: undefined }; evalContext?: Record<string, any>; evalRuleResults?: boolean[]; evalMissingFields?: string[]; } | { type: "event"; event: string; companyId?: IdType; userId: IdType; attributes?: Attributes; context?: TrackingMeta; }; /** * The SDK client. * * @remarks * This is the main class for interacting with Reflag. * It is used to evaluate flags, update user and company contexts, and track events. * * @example * ```ts * // set the REFLAG_SECRET_KEY environment variable or pass the secret key to the constructor * const client = new ReflagClient(); * * // evaluate a flag * const isFlagEnabled = client.getFlag("flag-key", { * user: { id: "user-id" }, * company: { id: "company-id" }, * }); * ``` **/ export class ReflagClient { private _config: { apiBaseUrl: string; refetchInterval: number; staleWarningInterval: number; headers: Record<string, string>; fallbackFlags?: RawFlags; flagOverrides: FlagOverridesFn; offline: boolean; configFile?: string; flagsFetchRetries: number; fetchTimeoutMs: number; cacheStrategy: CacheStrategy; }; httpClient: HttpClient; private flagsCache: Cache<CachedFlagDefinition[]>; private batchBuffer: BatchBuffer<BulkEvent>; private rateLimiter: ReturnType<typeof newRateLimiter>; /** * Gets the logger associated with the client. */ public readonly logger: Logger; private initializationFinished = false; private _initialize = once(async () => { const start = Date.now(); if (!this._config.offline) { await this.flagsCache.refresh(); } this.logger.info( "Reflag initialized in " + Math.round(Date.now() - start) + "ms" + (this._config.offline ? " (offline mode)" : ""), ); this.initializationFinished = true; }); /** * Creates a new SDK client. * See README for configuration options. * * @param options - The options for the client or an existing client to clone. * @param options.secretKey - The secret key to use for the client. * @param options.apiBaseUrl - The base URL to send requests to (optional). * @param options.logger - The logger to use for logging (optional). * @param options.httpClient - The HTTP client to use for sending requests (optional). * @param options.logLevel - The log level to use for logging (optional). * @param options.offline - Whether to run in offline mode (optional). * @param options.fallbackFlags - The fallback flags to use if the flag is not found (optional). * @param options.batchOptions - The options for the batch buffer (optional). * @param options.flagOverrides - The flag overrides to use for the client (optional). * @param options.configFile - The path to the config file (optional). * @param options.flagsFetchRetries - Number of retries for fetching flags (optional, defaults to 3). * @param options.fetchTimeoutMs - Timeout for fetching flags (optional, defaults to 10000ms). * @param options.cacheStrategy - The cache strategy to use for the client (optional, defaults to "periodically-update"). * * @throws An error if the options are invalid. **/ constructor(options: ClientOptions = {}) { ok(isObject(options), "options must be an object"); ok( options.host === undefined || (typeof options.host === "string" && options.host.length > 0), "host must be a string", ); ok( options.apiBaseUrl === undefined || (typeof options.apiBaseUrl === "string" && options.apiBaseUrl.length > 0), "apiBaseUrl must be a string", ); ok( options.logger === undefined || isObject(options.logger), "logger must be an object", ); ok( options.httpClient === undefined || isObject(options.httpClient), "httpClient must be an object", ); ok( options.fallbackFlags === undefined || Array.isArray(options.fallbackFlags) || isObject(options.fallbackFlags), "fallbackFlags must be an array or object", ); ok( options.batchOptions === undefined || isObject(options.batchOptions), "batchOptions must be an object", ); ok( options.configFile === undefined || typeof options.configFile === "string", "configFile must be a string", ); ok( options.flagsFetchRetries === undefined || (Number.isInteger(options.flagsFetchRetries) && options.flagsFetchRetries >= 0), "flagsFetchRetries must be a non-negative integer", ); ok( options.fetchTimeoutMs === undefined || (Number.isInteger(options.fetchTimeoutMs) && options.fetchTimeoutMs >= 0), "fetchTimeoutMs must be a non-negative integer", ); if (!options.configFile) { options.configFile = (process.env.REFLAG_CONFIG_FILE ?? fs.existsSync(reflagConfigDefaultFile)) ? reflagConfigDefaultFile : undefined; } const externalConfig = loadConfig(options.configFile); const config = mergeSkipUndefined(externalConfig, options); const offline = config.offline ?? process.env.NODE_ENV === "test"; if (!offline) { ok( typeof config.secretKey === "string", "secretKey must be a string, or set offline=true", ); ok(config.secretKey.length > 22, "invalid secretKey specified"); } // use the supplied logger or apply the log level to the console logger const logLevel = options.logLevel ?? config?.logLevel ?? "INFO"; this.logger = options.logger ? options.logger : applyLogLevel(decorateLogger(REFLAG_LOG_PREFIX, console), logLevel); const fallbackFlags = Array.isArray(options.fallbackFlags) ? options.fallbackFlags.reduce((acc, key) => { acc[key as TypedFlagKey] = { isEnabled: true, key, }; return acc; }, {} as RawFlags) : isObject(options.fallbackFlags) ? Object.entries(options.fallbackFlags).reduce( (acc, [key, fallback]) => { acc[key as TypedFlagKey] = { isEnabled: typeof fallback === "object" ? fallback.isEnabled : !!fallback, key, config: typeof fallback === "object" && fallback.config ? { key: fallback.config.key, payload: fallback.config.payload, } : undefined, }; return acc; }, {} as RawFlags, ) : undefined; this.rateLimiter = newRateLimiter(FLAG_EVENT_RATE_LIMITER_WINDOW_SIZE_MS); this.httpClient = options.httpClient || fetchClient; this.batchBuffer = new BatchBuffer<BulkEvent>({ ...options?.batchOptions, flushHandler: (items) => this.sendBulkEvents(items), logger: this.logger, }); this._config = { offline, apiBaseUrl: (config.apiBaseUrl ?? config.host) || API_BASE_URL, headers: { "Content-Type": "application/json", [SDK_VERSION_HEADER_NAME]: SDK_VERSION, ["Authorization"]: `Bearer ${config.secretKey}`, }, refetchInterval: FLAGS_REFETCH_MS, staleWarningInterval: FLAGS_REFETCH_MS * 5, fallbackFlags: fallbackFlags, flagOverrides: typeof config.flagOverrides === "function" ? config.flagOverrides : () => config.flagOverrides, flagsFetchRetries: options.flagsFetchRetries ?? 3, fetchTimeoutMs: options.fetchTimeoutMs ?? API_TIMEOUT_MS, cacheStrategy: options.cacheStrategy ?? "periodically-update", }; if ((config.batchOptions?.flushOnExit ?? true) && !this._config.offline) { triggerOnExit(() => this.flush()); } if (!new URL(this._config.apiBaseUrl).pathname.endsWith("/")) { this._config.apiBaseUrl += "/"; } const fetchFlags = async () => { const res = await this.get<FlagsAPIResponse>( "features", this._config.flagsFetchRetries, ); if (!isObject(res) || !Array.isArray(res?.features)) { this.logger.warn("flags cache: invalid response", res); return undefined; } return res.features.map((flagDef) => { return { ...flagDef, enabledEvaluator: newEvaluator( flagDef.targeting.rules.map((rule) => ({ filter: rule.filter, value: true, })), ), configEvaluator: flagDef.config ? newEvaluator( flagDef.config?.variants.map((variant) => ({ filter: variant.filter, value: { key: variant.key, payload: variant.payload, }, })), ) : undefined, } satisfies CachedFlagDefinition; }); }; if (this._config.cacheStrategy === "periodically-update") { this.flagsCache = periodicallyUpdatingCache<CachedFlagDefinition[]>( this._config.refetchInterval, this._config.staleWarningInterval, this.logger, fetchFlags, ); } else { this.flagsCache = inRequestCache<CachedFlagDefinition[]>( this._config.refetchInterval, this.logger, fetchFlags, ); } } /** * Sets the flag overrides. * * @param overrides - The flag overrides. * * @remarks * The flag overrides are used to override the flag definitions. * This is useful for testing or development. * * @example * ```ts * client.flagOverrides = { * "flag-1": true, * "flag-2": false, * }; * ``` **/ set flagOverrides(overrides: FlagOverridesFn | FlagOverrides) { if (typeof overrides === "object") { this._config.flagOverrides = () => overrides; } else { this._config.flagOverrides = overrides; } } /** * Clears the flag overrides. * * @remarks * This is useful for testing or development. * * @example * ```ts * afterAll(() => { * client.clearFlagOverrides(); * }); * ``` **/ clearFlagOverrides() { this._config.flagOverrides = () => ({}); } /** * Returns a new BoundReflagClient with the user/company/otherContext * set to be used in subsequent calls. * For example, for evaluating flag targeting or tracking events. * * @param context - The context to bind the client to. * @param context.enableTracking - Whether to enable tracking for the context. * @param context.user - The user context. * @param context.company - The company context. * @param context.other - The other context. * * @returns A new client bound with the arguments given. * * @throws An error if the user/company is given but their ID is not a string. * * @remarks * The `updateUser` / `updateCompany` methods will automatically be called when * the user/company is set respectively. **/ public bindClient({ enableTracking = true, ...context }: ContextWithTracking) { return new BoundReflagClient(this, { enableTracking, ...context }); } /** * Updates the associated user in Reflag. * * @param userId - The userId of the user to update. * @param options - The options for the user. * @param options.attributes - The additional attributes of the user (optional). * @param options.meta - The meta context associated with tracking (optional). * * @throws An error if the company is not set or the options are invalid. * @remarks * The company must be set using `withCompany` before calling this method. * If the user is set, the company will be associated with the user. **/ public async updateUser(userId: IdType, options?: TrackOptions) { idOk(userId, "userId"); ok(options === undefined || isObject(options), "options must be an object"); ok( options?.attributes === undefined || isObject(options.attributes), "attributes must be an object", ); checkMeta(options?.meta); if (this._config.offline) { return; } if (this.rateLimiter.isAllowed(hashObject({ ...options, userId }))) { await this.batchBuffer.add({ type: "user", userId, attributes: options?.attributes, context: options?.meta, }); } } /** * Updates the associated company in Reflag. * * @param companyId - The companyId of the company to update. * @param options - The options for the company. * @param options.attributes - The additional attributes of the company (optional). * @param options.meta - The meta context associated with tracking (optional). * @param options.userId - The userId of the user to associate with the company (optional). * * @throws An error if the company is not set or the options are invalid. * @remarks * The company must be set using `withCompany` before calling this method. * If the user is set, the company will be associated with the user. **/ public async updateCompany( companyId: IdType, options?: TrackOptions & { userId?: IdType }, ) { idOk(companyId, "companyId"); ok(options === undefined || isObject(options), "options must be an object"); ok( options?.attributes === undefined || isObject(options.attributes), "attributes must be an object", ); checkMeta(options?.meta); if (typeof options?.userId !== "undefined") { idOk(options?.userId, "userId"); } if (this._config.offline) { return; } if (this.rateLimiter.isAllowed(hashObject({ ...options, companyId }))) { await this.batchBuffer.add({ type: "company", companyId, userId: options?.userId, attributes: options?.attributes, context: options?.meta, }); } } /** * Tracks an event in Reflag. * @param options.companyId - Optional company ID for the event (optional). * * @throws An error if the user is not set or the event is invalid or the options are invalid. * @remarks * If the company is set, the event will be associated with the company. **/ public async track( userId: IdType, event: string, options?: TrackOptions & { companyId?: IdType }, ) { idOk(userId, "userId"); ok(typeof event === "string" && event.length > 0, "event must be a string"); ok(options === undefined || isObject(options), "options must be an object"); ok( options?.attributes === undefined || isObject(options.attributes), "attributes must be an object", ); ok( options?.meta === undefined || isObject(options.meta), "meta must be an object", ); if (options?.companyId !== undefined) { idOk(options?.companyId, "companyId"); } if (this._config.offline) { return; } await this.batchBuffer.add({ type: "event", event, companyId: options?.companyId, userId, attributes: options?.attributes, context: options?.meta, }); } /** * Initializes the client by caching the flags definitions. * * @remarks * Call this method before calling `getFlags` to ensure the flag definitions are cached. * The client will ignore subsequent calls to this method. **/ public async initialize() { await this._initialize(); return; } /** * Flushes and completes any in-flight fetches in the flag cache. * * @remarks * It is recommended to call this method when the application is shutting down to ensure all events are sent * before the process exits. * * This method is automatically called when the process exits if `batchOptions.flushOnExit` is `true` in the options (default). */ public async flush() { if (this._config.offline) { return; } await this.batchBuffer.flush(); await this.flagsCache.waitRefresh(); } /** * Destroys the client and cleans up all resources including timers and background processes. * * @remarks * After calling this method, the client should not be used anymore. * This is particularly useful in development environments with hot reloading to prevent * multiple background processes from running simultaneously. */ public destroy() { this.flagsCache.destroy(); this.batchBuffer.destroy(); } /** * Gets the flag definitions, including all config values. * To evaluate which flags are enabled for a given user/company, use `getFlags`. * * @returns The flags definitions. */ public getFlagDefinitions(): FlagDefinition[] { const flags = this.flagsCache.get() || []; return flags.map((f) => ({ key: f.key, description: f.description, flag: f.targeting, config: f.config, })); } /** * Gets the evaluated flags for the current context which includes the user, company, and custom context. * * @param options - The options for the context. * @param options.enableTracking - Whether to enable tracking for the context. * @param options.meta - The meta context associated with the context. * @param options.user - The user context. * @param options.company - The company context. * @param options.other - The other context. * * @returns The evaluated flags. * * @remarks * Call `initialize` before calling this method to ensure the flag definitions are cached, no flags will be returned otherwise. **/ public getFlags({ enableTracking = true, ...context }: ContextWithTracking): TypedFlags { const contextWithTracking = { enableTracking, ...context }; const rawFlags = this._getFlags(contextWithTracking); return Object.fromEntries( Object.entries(rawFlags).map(([key, rawFlag]) => [ key, this._wrapRawFlag(contextWithTracking, rawFlag), ]), ); } /** * Gets the evaluated flag for the current context which includes the user, company, and custom context. * Using the `isEnabled` property sends a `check` event to Reflag. * * @param key - The key of the flag to get. * @returns The evaluated flag. * * @remarks * Call `initialize` before calling this method to ensure the flag definitions are cached, no flags will be returned otherwise. **/ public getFlag<TKey extends TypedFlagKey>( { enableTracking = true, ...context }: ContextWithTracking, key: TKey, ): TypedFlags[TKey] { const contextWithTracking = { enableTracking, ...context }; const rawFlag = this._getFlags(contextWithTracking, key); return this._wrapRawFlag( { enableChecks: true, ...contextWithTracking }, rawFlag ?? { key }, ); } /** * Gets the evaluated flags for the current context without wrapping them in getters. * This method returns raw flag data suitable for bootstrapping client-side applications. * * @param options - The options for the context. * @param options.enableTracking - Whether to enable tracking for the context. * @param options.meta - The meta context associated with the context. * @param options.user - The user context. * @param options.company - The company context. * @param options.other - The other context. * * @returns The evaluated raw flags and the context. * * @remarks * Call `initialize` before calling this method to ensure the flag definitions are cached, no flags will be returned otherwise. * This method returns RawFlag objects without wrapping them in getters, making them suitable for serialization. **/ public getFlagsForBootstrap({ enableTracking = true, ...context }: ContextWithTracking): BootstrappedFlags { const contextWithTracking = { enableTracking, ...context }; return { context: contextWithTracking, flags: this._getFlags(contextWithTracking), }; } /** * Gets evaluated flags with the usage of remote context. * This method triggers a network request every time it's called. * * @param userId - The userId of the user to get the flags for. * @param companyId - The companyId of the company to get the flags for. * @param additionalContext - The additional context to get the flags for. * * @returns evaluated flags */ public async getFlagsRemote( userId?: IdType, companyId?: IdType, additionalContext?: Context, ): Promise<TypedFlags> { return this._getFlagsRemote( undefined, userId, companyId, additionalContext, ); } /** * Gets evaluated flag with the usage of remote context. * This method triggers a network request every time it's called. * * @param key - The key of the flag to get. * @param userId - The userId of the user to get the flag for. * @param companyId - The companyId of the company to get the flag for. * @param additionalContext - The additional context to get the flag for. * * @returns evaluated flag */ public async getFlagRemote<TKey extends TypedFlagKey>( key: TKey, userId?: IdType, companyId?: IdType, additionalContext?: Context, ): Promise<TypedFlags[TKey]> { return this._getFlagsRemote(key, userId, companyId, additionalContext); } private buildUrl(path: string) { if (path.startsWith("/")) { path = path.slice(1); } const url = new URL(path, this._config.apiBaseUrl); return url.toString(); } /** * Sends a POST request to the specified path. * * @param path - The path to send the request to. * @param body - The body of the request. * * @returns A boolean indicating if the request was successful. * * @throws An error if the path or body is invalid. **/ private async post<TBody>(path: string, body: TBody) { ok(typeof path === "string" && path.length > 0, "path must be a string"); ok(typeof body === "object", "body must be an object"); const url = this.buildUrl(path); try { const response = await this.httpClient.post<TBody, { success: boolean }>( url, this._config.headers, body, ); this.logger.debug(`post request to "${url}"`, response); if (!response.ok || !isObject(response.body) || !response.body.success) { this.logger.warn( `invalid response received from server for "${url}"`, JSON.stringify(response), ); return false; } return true; } catch (error) { this.logger.error(`post request to "${url}" failed with error`, error); return false; } } /** * Sends a GET request to the specified path. * * @param path - The path to send the request to. * @param retries - Optional number of retries for the request. * * @returns The response from the server. * @throws An error if the path is invalid. **/ private async get<TResponse>(path: string, retries: number = 3) { ok(typeof path === "string" && path.length > 0, "path must be a string"); try { const url = this.buildUrl(path); return await withRetry( async () => { const response = await this.httpClient.get< TResponse & { success: boolean } >(url, this._config.headers, this._config.fetchTimeoutMs); this.logger.debug(`get request to "${url}"`, response); if ( !response.ok || !isObject(response.body) || !response.body.success ) { throw new Error( `invalid response received from server for "${url}": ${JSON.stringify(response.body)}`, ); } const { success: _, ...result } = response.body; return result as TResponse; }, () => { this.logger.warn("failed to fetch flags, will retry"); }, retries, 1000, 10000, ); } catch (error) { this.logger.error( `get request to "${path}" failed with error after ${retries} retries`, error, ); return undefined; } } /** * Sends a batch of events to the Reflag API. * * @param events - The events to send. * * @throws An error if the send fails. **/ private async sendBulkEvents(events: BulkEvent[]) { ok( Array.isArray(events) && events.length > 0, "events must be a non-empty array", ); const sent = await this.post("bulk", events); if (!sent) { throw new Error("Failed to send bulk events"); } } /** * Sends a flag event to the Reflag API. * * Flag events are used to track the evaluation of flag targeting rules. * "check" events are sent when a flag's `isEnabled` property is checked. * "evaluate" events are sent when a flag's targeting rules are matched against * the current context. * * @param event - The event to send. * @param event.action - The action to send. * @param event.key - The key of the flag to send. * @param event.targetingVersion - The targeting version of the flag to send. * @param event.evalResult - The evaluation result of the flag to send. * @param event.evalContext - The evaluation context of the flag to send. * @param event.evalRuleResults - The evaluation rule results of the flag to send. * @param event.evalMissingFields - The evaluation missing fields of the flag to send. * * @throws An error if the event is invalid. * * @remarks * This method is rate-limited to prevent too many events from being sent. **/ private async sendFlagEvent(event: FlagEvent) { ok(typeof event === "object", "event must be an object"); ok( typeof event.action === "string" && (event.action === "check" || event.action === "check-config"), "event must have an action", ); ok( typeof event.key === "string" && event.key.length > 0, "event must have a flag key", ); ok( typeof event.targetingVersion === "number" || event.targetingVersion === undefined, "event must have a targeting version", ); ok( typeof event.evalResult === "boolean" || isObject(event.evalResult), "event must have an evaluation result", ); ok( event.evalContext === undefined || typeof event.evalContext === "object", "event context must be an object", ); ok( event.evalRuleResults === undefined || Array.isArray(event.evalRuleResults), "event rule results must be an array", ); ok( event.evalMissingFields === undefined || Array.isArray(event.evalMissingFields), "event missing fields must be an array", ); const contextKey = new URLSearchParams( flattenJSON(event.evalContext || {}), ).toString(); if (this._config.offline) { return; } if ( !this.rateLimiter.isAllowed( hashObject({ action: event.action, key: event.key, targetingVersion: event.targetingVersion, evalResult: event.evalResult, contextKey, }), ) ) { return; } await this.batchBuffer.add({ type: "feature-flag-event", action: event.action, key: event.key, targetingVersion: event.targetingVersion, evalContext: event.evalContext, evalResult: event.evalResult, evalRuleResults: event.evalRuleResults, evalMissingFields: event.evalMissingFields, }); } /** * Updates the context in Reflag (if needed). * This method should be used before requesting flags or binding a client. * * @param options - The options for the context. * @param options.enableTracking - Whether to enable tracking for the context. * @param options.meta - The meta context associated with the context. * @param options.user - The user context. * @param options.company - The company context. * @param options.other - The other context. */ private async syncContext(options: ContextWithTracking) { if (!options.enableTracking) { this.logger.debug("tracking disabled, not updating user/company"); return; } const promises: Promise<void>[] = []; if (typeof options.company?.id !== "undefined") { const { id: _, ...attributes } = options.company; promises.push( this.updateCompany(options.company.id, { attributes, meta: options.meta, }), ); } if (typeof options.user?.id !== "undefined") { const { id: _, ...attributes } = options.user; promises.push( this.updateUser(options.user.id, { attributes, meta: options.meta, }), ); } if (promises.length > 0) { await Promise.all(promises); } } /** * Warns if a flag has targeting rules that require context fields that are missing. * * @param context - The context. * @param flag - The flag to check. */ private _warnMissingFlagContextFields( context: Context, flag: { key: string; missingContextFields?: string[]; config?: { key: string; missingContextFields?: string[]; }; }, ) { const report: Record<string, string[]> = {}; const { config, ...flagData } = flag; if ( flagData.missingContextFields?.length && this.rateLimiter.isAllowed( hashObject({ flagKey: flagData.key, missingContextFields: flagData.missingContextFields, context, }), ) ) { report[flagData.key] = flagData.missingContextFields; } if ( config?.missingContextFields?.length && this.rateLimiter.isAllowed( hashObject({ flagKey: flagData.key, configKey: config.key, missingContextFields: config.missingContextFields, context, }), ) ) { report[`${flagData.key}.config`] = config.missingContextFields; } if (Object.keys(report).length > 0) { this.logger.warn( `flag targeting rules might not be correctly evaluated due to missing context fields.`, report, ); } } private _getFlags(options: ContextWithTracking): RawFlags; private _getFlags<TKey extends TypedFlagKey>( options: ContextWithTracking, key: TKey, ): RawFlag | undefined; private _getFlags<TKey extends TypedFlagKey>( options: ContextWithTracking, key?: TKey, ): RawFlags | RawFlag | undefined { checkContextWithTracking(options); if (!this.initializationFinished) { this.logger.error("getFlag(s): ReflagClient is not initialized yet."); } void this.syncContext(options); let flagDefinitions: CachedFlagDefinition[] = []; if (!this._config.offline) { const flagDefs = this.flagsCache.get(); if (!flagDefs) { this.logger.warn( "no flag definitions available, using fallback flags.", ); const fallbackFlags = this._config.fallbackFlags || {}; if (key) { return fallbackFlags[key]; } return fallbackFlags; } flagDefinitions = flagDefs; } const { enableTracking: _, meta: __, ...context } = options; const evaluated = flagDefinitions .filter(({ key: flagKey }) => (key ? key === flagKey : true)) .map((flag) => ({ flagKey: flag.key, targetingVersion: flag.targeting.version, configVersion: flag.config?.version, enabledResult: flag.enabledEvaluator(context, flag.key), configResult: flag.configEvaluator?.(context, flag.key) ?? ({ flagKey: flag.key, context, value: undefined, ruleEvaluationResults: [], missingContextFields: [], } satisfies EvaluationResult<any>), })); let evaluatedFlags = evaluated.reduce((acc, res) => { acc[res.flagKey as TypedFlagKey] = { key: res.flagKey, isEnabled: res.enabledResult.value ?? false, ruleEvaluationResults: res.enabledResult.ruleEvaluationResults, missingContextFields: res.enabledResult.missingContextFields, targetingVersion: res.targetingVersion, config: { key: res.configResult?.value?.key, payload: res.configResult?.value?.payload, targetingVersion: res.configVersion, ruleEvaluationResults: res.configResult?.ruleEvaluationResults, missingContextFields: res.configResult?.missingContextFields, }, }; return acc; }, {} as RawFlags); const overrides = Object.entries(this._config.flagOverrides(context)) .filter(([flagKey]) => (key ? key === flagKey : true)) .map(([flagKey, override]) => [ flagKey, isObject(override) ? { key: flagKey, isEnabled: override.isEnabled, config: override.config, } : { key: flagKey, isEnabled: !!override, config: undefined, }, ]); if (overrides.length > 0) { // merge overrides into evaluated flags evaluatedFlags = { ...evaluatedFlags, ...Object.fromEntries(overrides), }; } if (key) { return evaluatedFlags[key]; } return evaluatedFlags; } private _wrapRawFlag<TKey extends TypedFlagKey>( { enableTracking, enableChecks = false, ...context }: { enableTracking: boolean; enableChecks?: boolean } & Context, { config, ...flag }: PartialBy<RawFlag, "isEnabled">, ): TypedFlags[TKey] { // eslint-disable-next-line @typescript-eslint/no-this-alias const client = this; const simplifiedConfig = config ? { key: config.key, payload: config.payload } : { key: undefined, payload: undefined }; return { get isEnabled() { if (enableTracking && enableChecks) { client._warnMissingFlagContextFields(context, flag); void client .sendFlagEvent({ action: "check", key: flag.key, targetingVersion: flag.targetingVersion, evalResult: flag.isEnabled ?? false, evalContext: context, evalRuleResults: flag.ruleEvaluationResults, evalMissingFields: flag.missingContextFields, }) .catch((err) => { client.logger?.error( `failed to send check event for "${flag.key}": ${err}`, err, ); }); } return flag.isEnabled ?? false; }, get config() { if (enableTracking && enableChecks) { client._warnMissingFlagContextFields(context, flag); void client .sendFlagEvent({ action: "check-config", key: flag.key, targetingVersion: config?.targetingVersion, evalResult: simplifiedConfig, evalContext: context, evalRuleResults: config?.ruleEvaluationResults, evalMissingFields: config?.missingContextFields, }) .catch((err) => { client.logger?.error( `failed to send check event for "${flag.key}": ${err}`, err, ); }); } return simplifiedConfig as TypedFlags[TKey]["config"]; }, key: flag.key, track: async () => { if (typeof context.user?.id === "undefined") { this.logger.warn("no user set, cannot track event"); return; } if (enableTracking) { await this.track(context.user.id, flag.key, { companyId: context.company?.id, }); } else { this.logger.debug("tracking disabled, not tracking event"); } }, }; } private async _getFlagsRemote( key: undefined, userId?: IdType, companyId?: IdType, additionalContext?: Context, ): Promise<TypedFlags>; private async _getFlagsRemote<TKey extends TypedFlagKey>( key: TKey, userId?: IdType, companyId?: IdType, additionalContext?: Context, ): Promise<TypedFlags[TKey]>; private async _getFlagsRemote<TKey extends TypedFlagKey>( key?: string, userId?: IdType, companyId?: IdType, additionalContext?: Context, ): Promise<TypedFlags | TypedFlags[TKey]> { const context = additionalContext || {}; if (userId) { context.user = { id: userId }; } if (companyId) { context.company = { id: companyId }; } const contextWithTracking = { ...context, enableTracking: true, }; checkContextWithTracking(contextWithTracking); const params = new URLSearchParams( Object.keys(context).length ? flattenJSON({ context }) : undefined, ); if (key) { params.append("key", key); } const res = await this.get<EvaluatedFlagsAPIResponse>( `features/evaluated?${params}`, ); if (key) { const flag = res?.features[key]; if (!flag) { this.logger.error(`flag ${key} not found`); } return this._wrapRawFlag(contextWithTracking, { key, ...flag }); } else { return res?.features ? Object.fromEntries( Object.entries(res?.features).map(([flagKey, flag]) => [ flagKey, this._wrapRawFlag(contextWithTracking, flag), ]), ) : {}; } } } /** * A client bound with a specific user, company, and other context. */ export class BoundReflagClient { private readonly _client: ReflagClient; private readonly _options: ContextWithTracking; /** * (Internal) Creates a new BoundReflagClient. Use `bindClient` to create a new client bound with a specific context. * * @param client - The `ReflagClient` to use. * @param options - The options for the client. * @param options.enableTracking - Whether to enable tracking for the client. * * @internal */ constructor( client: ReflagClient, { enableTracking = true, ...context }: ContextWithTracking, ) { this._client = client; this._options = { enableTracking, ...context }; checkContextWithTracking(this._options); void this._client["syncContext"](this._options); } /** * Gets the "other" context associated with the client. * * @returns The "other" context or `undefined` if it is not set. **/ public get otherContext() { return this._options.other; } /** * Gets the user associated with the client. * * @returns The user or `undefined` if it is not set. **/ public get user() { return this._options.user; } /** * Gets the company associated with the client. * * @returns The company or `undefined` if it is not set. **/ public get company() { return this._options.company; } /** * Get flags for the user/company/other context bound to this client. * Meant for use in serialization of flags for transferring to the client-side/browser. * * @returns Flags for the given user/company and whether each one is enabled or not */ public getFlags(): TypedFlags { return this._client.getFlags(this._options); } /** * Get raw flags for the user/company/other context bound to this client without wrapping them in getters. * This method returns raw flag data suitable for bootstrapping client-side applications. * * @returns Raw flags for the given user/company and whether each one is enabled or not */ public getFlagsForBootstrap(): BootstrappedFlags { return this._client.getFlagsForBootstrap({ ...this._options }); } /** * Get a specific flag for the user/company/other context bound to this client. * Using the `isEnabled` property sends a `check` event to Reflag. * * @param key - The key of the flag to get. * * @returns Flags for the given user/company and whether each one is enabled or not */ public getFlag<TKey extends TypedFlagKey>(key: TKey): TypedFlags[TKey] { return this._client.getFlag(this._options, key); } /** * Get remotely evaluated flag for the user/company/other context bound to this client. * * @returns Flags for the given user/company and whether each one is enabled or not */ public async getFlagsRemote() { const { enableTracking: _, meta: __, ...context } = this._options; return await this._client.getFlagsRemote(undefined, undefined, context); } /** * Get remotely evaluated flag for the user/company/other context bound to this client. * * @param key - The key of the flag to get. * * @returns Flag for the given user/company and key and whether it's enabled or not */ public async getFlagRemote(key: string) { const { enableTracking: _, meta: __, ...context } = this._options; return await this._client.getFlagRemote(key, undefined, undefined, context); } /** * Track an event in Reflag. * * @param event - The event to track. * @param options - The options for the event. * @param options.attributes - The attributes of the event (optional). * @param options.meta - The meta context associated with tracking (optional). * @param options.companyId - Optional company ID for the event (optional). * * @throws An error if the event is invalid or the options are invalid. */ public async track( event: string, options?: TrackOptions & { companyId?: string }, ) { ok(options === undefined || isObject(options), "options must be an object"); checkMeta(options?.meta); const userId = this._options.user?.id; if (!userId) { this._client.logger?.warn("no user set, cannot track event"); return; } if (!this._options.enableTracking) { this._client.logger?.debug( "tracking disabled for this bound client, not tracking event", ); return; } await this._client.track( userId, event, options?.companyId ? options : { ...options, companyId: this._options.company?.id }, ); } /** * Create a new client bound with the additional context. * Note: This performs a shallow merge for user/company/other individually. * * @param context - The context to bind the client to. * @param context.user - The user to bind the client to. * @param context.company - The company to bind the client to. * @param context.other - The other context to bind the client to. * @param context.enableTracking - Whether to enable tracking for the client. * @param context.meta - The meta context to bind the client to. * * @returns new client bound with the additional context */ public bindClient({ user, company, other, enableTracking, meta, }: ContextWithTracking) { // merge new context into existing const boundConfig = { ...this._options, user: user ? { ...this._options.user, ...user } : undefined, company: company ? { ...this._options.company, ...company } : undefined, other: { ...this._options.other, ...other }, enableTracking: enableTracking ?? this._options.enableTracking, meta: meta ?? this._options.meta, }; return new BoundReflagClient(this._client, boundConfig); } /** * Flushes the batch buffer. */ public async flush() { await this._client.flush(); } } function checkMeta( meta?: TrackingMeta, ): asserts meta is TrackingMeta | undefined { ok( typeof meta === "undefined" || isObject(meta), "meta must be an object if given", ); ok( meta?.active === undefined || typeof meta?.active === "boolean", "meta.active must be a boolean if given", ); } function checkContext(context: Context): asserts context is Context { ok(isObject(context), "context must be an object"); ok( typeof context.user === "undefined" || isObject(context.user), "user must be an object if given", ); if (typeof context.user?.id !== "undefined") { idOk(context.user.id, "user.id"); } ok( typeof context.company === "undefined" || isObject(context.company), "company must be an object if given", ); if (typeof context.company?.id !== "undefined") { idOk(context.company.id, "company.id"); } ok( context.other === undefined || isObject(context.other), "other must be an object if given", ); } function checkContextWithTracking( context: ContextWithTracking, ): asserts context is ContextWithTracking & { enableTracking: boolean } { checkContext(context); ok( typeof context.enableTracking === "boolean", "enableTracking must be a boolean", ); checkMeta(context.meta); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/reflagcom/bucket-javascript-sdk'

If you have feedback or need assistance with the MCP directory API, please join our Discord server