Skip to main content
Glama
reflagcom

Bucket Feature Flags MCP Server

Official
by reflagcom
flags.ts14.8 kB
import { deepEqual } from "fast-equals"; import { FLAG_EVENTS_PER_MIN, FLAGS_EXPIRE_MS, IS_SERVER } from "../config"; import { ReflagContext } from "../context"; import { HttpClient } from "../httpClient"; import { Logger, loggerWithPrefix } from "../logger"; import RateLimiter from "../rateLimiter"; import { FlagCache, isObject, parseAPIFlagsResponse } from "./flagCache"; /** * A flag fetched from the server. */ export type RawFlag = { /** * Flag key. */ key: string; /** * Result of flag evaluation. * Note: does not take local overrides into account. */ isEnabled: boolean; /** * If not null or undefined, the result is being overridden locally */ isEnabledOverride?: boolean | null; /** * Version of targeting rules. */ targetingVersion?: number; /** * Rule evaluation results. */ ruleEvaluationResults?: boolean[]; /** * Missing context fields. */ missingContextFields?: string[]; /** * Optional user-defined dynamic configuration. */ config?: { /** * The key of the matched configuration value. */ key: string; /** * The version of the matched configuration value. */ version?: number; /** * The optional user-supplied payload data. */ payload?: any; /** * The rule evaluation results. */ ruleEvaluationResults?: boolean[]; /** * The missing context fields. */ missingContextFields?: string[]; }; }; export type RawFlags = Record<string, RawFlag>; export type FallbackFlagOverride = | { key: string; payload: any; } | true; type FallbackFlags = Record<string, FallbackFlagOverride>; type Config = { timeoutMs: number; staleTimeMs: number; staleWhileRevalidate: boolean; expireTimeMs: number; offline: boolean; }; export const DEFAULT_FLAGS_CONFIG: Config = { timeoutMs: 5000, staleTimeMs: 0, staleWhileRevalidate: false, expireTimeMs: FLAGS_EXPIRE_MS, offline: false, }; export function validateFlagsResponse(response: any) { if (!isObject(response)) { return; } if (typeof response.success !== "boolean" || !isObject(response.features)) { return; } const flags = parseAPIFlagsResponse(response.features); if (!flags) { return; } return { success: response.success, flags, }; } export function flattenJSON(obj: Record<string, any>): Record<string, any> { const result: Record<string, any> = {}; for (const key in obj) { if (typeof obj[key] === "object") { const flat = flattenJSON(obj[key]); for (const flatKey in flat) { result[`${key}.${flatKey}`] = flat[flatKey]; } } else if (typeof obj[key] !== "undefined") { result[key] = obj[key]; } } return result; } /** * Event representing checking the flag evaluation result */ export interface CheckEvent { /** * `check-is-enabled` means `isEnabled` was checked, `check-config` means `config` was checked. */ action: "check-is-enabled" | "check-config"; /** * Flag key. */ key: string; /** * Result of flag or configuration evaluation. * If `action` is `check-is-enabled`, this is the result of the flag evaluation and `value` is a boolean. * If `action` is `check-config`, this is the result of the configuration evaluation. */ value?: boolean | { key: string; payload: any }; /** * Version of targeting rules. */ version?: number; /** * Rule evaluation results. */ ruleEvaluationResults?: boolean[]; /** * Missing context fields. */ missingContextFields?: string[]; } const localStorageFetchedFlagsKey = `__reflag_fetched_flags`; const storageOverridesKey = `__reflag_overrides`; export type FlagOverrides = Record<string, boolean | undefined>; type FlagsClientOptions = Partial<Config> & { bootstrappedFlags?: RawFlags; fallbackFlags?: Record<string, FallbackFlagOverride> | string[]; cache?: FlagCache; rateLimiter?: RateLimiter; }; /** * @internal */ export class FlagsClient { private initialized = false; private bootstrapped = false; private rateLimiter: RateLimiter; private readonly logger: Logger; private cache: FlagCache; private fetchedFlags: RawFlags = {}; private flagOverrides: FlagOverrides = {}; private flags: RawFlags = {}; private fallbackFlags: FallbackFlags = {}; private config: Config = DEFAULT_FLAGS_CONFIG; private eventTarget = new EventTarget(); private abortController: AbortController = new AbortController(); constructor( private httpClient: HttpClient, private context: ReflagContext, logger: Logger, { bootstrappedFlags, cache, rateLimiter, fallbackFlags, ...config }: FlagsClientOptions = {}, ) { this.config = { ...this.config, ...config, }; this.logger = loggerWithPrefix(logger, "[Flags]"); this.rateLimiter = rateLimiter ?? new RateLimiter(FLAG_EVENTS_PER_MIN, this.logger); this.cache = cache ?? this.setupCache(this.config.staleTimeMs, this.config.expireTimeMs); this.fallbackFlags = this.setupFallbackFlags(fallbackFlags); if (bootstrappedFlags) { this.bootstrapped = true; this.setFetchedFlags(bootstrappedFlags, false); } this.flagOverrides = this.getOverridesCache(); } async initialize() { if (this.initialized) { this.logger.warn("flags client already initialized"); return; } this.initialized = true; if (!this.bootstrapped) { this.setFetchedFlags((await this.maybeFetchFlags()) || {}); } // Apply overrides and trigger update if flags have changed this.updateFlags(); } /** * Stop the client. */ public stop() { this.abortController.abort(); } getFlags(): RawFlags { return this.flags; } getFetchedFlags(): RawFlags { return this.fetchedFlags; } setFetchedFlags(fetchedFlags: RawFlags, triggerEvent = true) { // Create a new fetched flags object making sure to clone the flags this.fetchedFlags = { ...fetchedFlags }; this.warnMissingFlagContextFields(fetchedFlags); this.updateFlags(triggerEvent); } async setContext(context: ReflagContext) { this.context = context; this.setFetchedFlags((await this.maybeFetchFlags()) || {}); } updateFlags(triggerEvent = true) { const updatedFlags = this.mergeFlags(this.fetchedFlags, this.flagOverrides); // Nothing has changed, skipping update if (deepEqual(this.flags, updatedFlags)) return; this.flags = updatedFlags; if (triggerEvent) this.triggerFlagsUpdated(); } setFlagOverride(key: string, isEnabled: boolean | null) { if (!(typeof isEnabled === "boolean" || isEnabled === null)) { throw new Error("setFlagOverride: isEnabled must be boolean or null"); } if (isEnabled === null) { delete this.flagOverrides[key]; } else { this.flagOverrides[key] = isEnabled; } this.setOverridesCache(this.flagOverrides); this.updateFlags(); } getFlagOverride(key: string): boolean | null { return this.flagOverrides[key] ?? null; } /** * Register a callback to be called when the flags are updated. * Flags are not guaranteed to have actually changed when the callback is called. * * @param callback this will be called when the flags are updated. * @returns a function that can be called to remove the listener */ onUpdated(callback: () => void) { this.eventTarget.addEventListener("flagsUpdated", callback, { signal: this.abortController.signal, }); } /** * Send a flag "check" event. * * * @param checkEvent - The flag to send the event for. * @param cb - Callback to call after the event is sent. Might be skipped if the event was rate limited. */ async sendCheckEvent(checkEvent: CheckEvent, cb: () => void) { if (this.config.offline) { return; } const rateLimitKey = `check-event:${this.fetchParams().toString()}:${checkEvent.key}:${checkEvent.version}:${checkEvent.value}`; await this.rateLimiter.rateLimited(rateLimitKey, async () => { const payload = { action: checkEvent.action, key: checkEvent.key, targetingVersion: checkEvent.version, evalContext: this.context, evalResult: checkEvent.value, evalRuleResults: checkEvent.ruleEvaluationResults, evalMissingFields: checkEvent.missingContextFields, }; this.httpClient .post({ path: "features/events", body: payload, }) .catch((e: any) => { this.logger.warn(`failed to send flag check event`, e); }); this.logger.debug(`sent flag event`, payload); cb(); }); return checkEvent.value; } async fetchFlags(): Promise<RawFlags | undefined> { const params = this.fetchParams(); try { const res = await this.httpClient.get({ path: "/features/evaluated", timeoutMs: this.config.timeoutMs, params, }); if (!res.ok) { let errorBody = null; try { errorBody = await res.json(); } catch { // ignore } throw new Error( "unexpected response code: " + res.status + " - " + JSON.stringify(errorBody), ); } const typeRes = validateFlagsResponse(await res.json()); if (!typeRes || !typeRes.success) { throw new Error("unable to validate response"); } return typeRes.flags; } catch (e) { this.logger.error("error fetching flags: ", e); return; } } private setOverridesCache(overrides: FlagOverrides) { if (IS_SERVER) return; try { localStorage.setItem(storageOverridesKey, JSON.stringify(overrides)); } catch (error) { this.logger.warn( "storing flag overrides in localStorage failed, overrides won't persist", error, ); } } private getOverridesCache(): FlagOverrides { if (IS_SERVER) return {}; try { const overridesStored = localStorage.getItem(storageOverridesKey); const overrides = JSON.parse(overridesStored || "{}"); if (!isObject(overrides)) throw new Error("invalid overrides"); return overrides; } catch (error) { this.logger.warn( "getting flag overrides from localStorage failed", error, ); return {}; } } private async maybeFetchFlags(): Promise<RawFlags | undefined> { if (this.config.offline) { return; } const cacheKey = this.fetchParams().toString(); const cachedItem = this.cache.get(cacheKey); if (cachedItem) { if (!cachedItem.stale) return cachedItem.flags; // serve successful stale cache if `staleWhileRevalidate` is enabled if (this.config.staleWhileRevalidate) { // re-fetch in the background, but immediately return last successful value this.fetchFlags() .then((flags) => { if (!flags) return; this.cache.set(cacheKey, { flags, }); this.setFetchedFlags(flags); }) .catch(() => { // we don't care about the result, we just want to re-fetch }); return cachedItem.flags; } } // if there's no cached item or there is a stale one but `staleWhileRevalidate` is disabled // try fetching a new one const fetchedFlags = await this.fetchFlags(); if (fetchedFlags) { this.cache.set(cacheKey, { flags: fetchedFlags, }); return fetchedFlags; } if (cachedItem) { // fetch failed, return stale cache return cachedItem.flags; } // fetch failed, nothing cached => return fallbacks return Object.entries(this.fallbackFlags).reduce((acc, [key, override]) => { acc[key] = { key, isEnabled: !!override, config: typeof override === "object" && "key" in override ? { key: override.key, payload: override.payload, } : undefined, }; return acc; }, {} as RawFlags); } private mergeFlags(fetchedFlags: RawFlags, overrides: FlagOverrides) { const mergedFlags: RawFlags = {}; // merge fetched flags with overrides into `this.flags` for (const key in fetchedFlags) { const fetchedFlag = fetchedFlags[key]; if (!fetchedFlag) continue; const isEnabledOverride = overrides[key] ?? null; mergedFlags[key] = { ...fetchedFlag, isEnabledOverride }; } return mergedFlags; } private triggerFlagsUpdated() { this.eventTarget.dispatchEvent(new Event("flagsUpdated")); } private setupCache(staleTimeMs = 0, expireTimeMs = FLAGS_EXPIRE_MS) { return new FlagCache({ storage: !IS_SERVER ? { get: () => localStorage.getItem(localStorageFetchedFlagsKey), set: (value) => localStorage.setItem(localStorageFetchedFlagsKey, value), } : { get: () => null, set: () => void 0, }, staleTimeMs, expireTimeMs, }); } private setupFallbackFlags( fallbackFlags?: Record<string, FallbackFlagOverride> | string[], ) { if (Array.isArray(fallbackFlags)) { return fallbackFlags.reduce( (acc, key) => { acc[key] = true; return acc; }, {} as Record<string, FallbackFlagOverride>, ); } else { return fallbackFlags ?? {}; } } private fetchParams() { const flattenedContext = flattenJSON({ context: this.context }); const params = new URLSearchParams(flattenedContext); // publishableKey should be part of the cache key params.append("publishableKey", this.httpClient.publishableKey); // sort the params to ensure that the URL is the same for the same context params.sort(); return params; } private warnMissingFlagContextFields(flags: RawFlags) { const report: Record<string, string[]> = {}; for (const flagKey in flags) { const flag = flags[flagKey]; if (flag?.missingContextFields?.length) { report[flag.key] = flag.missingContextFields; } if (flag?.config?.missingContextFields?.length) { report[`${flag.key}.config`] = flag.config.missingContextFields; } } if (Object.keys(report).length > 0) { this.rateLimiter.rateLimited( `flag-missing-context-fields:${this.fetchParams().toString()}`, () => { this.logger.warn( `flag targeting rules might not be correctly evaluated due to missing context fields.`, report, ); }, ); } } }

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