Skip to main content
Glama

Bucket Feature Flags MCP Server

Official
by reflagcom
client.ts25.3 kB
import { deepEqual } from "fast-equals"; import { AutoFeedback, Feedback, feedback, FeedbackOptions, RequestFeedbackData, RequestFeedbackOptions, } from "./feedback/feedback"; import * as feedbackLib from "./feedback/ui"; import { CheckEvent, FallbackFlagOverride, FlagsClient, RawFlags, } from "./flag/flags"; import { ToolbarPosition } from "./ui/types"; import { API_BASE_URL, APP_BASE_URL, IS_SERVER, SSE_REALTIME_BASE_URL, } from "./config"; import { ReflagContext, ReflagDeprecatedContext } from "./context"; import { HookArgs, HooksManager, State } from "./hooksManager"; import { HttpClient } from "./httpClient"; import { Logger, loggerWithPrefix, quietConsoleLogger } from "./logger"; import { showToolbarToggle } from "./toolbar"; const isMobile = typeof window !== "undefined" && window.innerWidth < 768; const isNode = typeof document === "undefined"; // deno supports "window" but not "document" according to https://remix.run/docs/en/main/guides/gotchas /** * (Internal) User context. * * @internal */ export type User = { /** * Identifier of the user. */ userId: string; /** * User attributes. */ attributes?: { /** * Name of the user. */ name?: string; /** * Email of the user. */ email?: string; /** * Avatar URL of the user. */ avatar?: string; /** * Custom attributes of the user. */ [key: string]: any; }; /** * Custom context of the user. */ context?: PayloadContext; }; /** * (Internal) Company context. * * @internal */ export type Company = { /** * User identifier. */ userId: string; /** * Company identifier. */ companyId: string; /** * Company attributes. */ attributes?: { /** * Name of the company. */ name?: string; /** * Custom attributes of the company. */ [key: string]: any; }; context?: PayloadContext; }; /** * Tracked event. */ export type TrackedEvent = { /** * Event name. */ event: string; /** * User identifier. */ userId: string; /** * Company identifier. */ companyId?: string; /** * Event attributes. */ attributes?: Record<string, any>; /** * Custom context of the event. */ context?: PayloadContext; }; /** * (Internal) Custom context of the event. * * @internal */ export type PayloadContext = { /** * Whether the company and user associated with the event are active. */ active?: boolean; }; /** * ReflagClient configuration. */ export interface Config { /** * Base URL of Reflag servers. */ apiBaseUrl: string; /** * Base URL of the Reflag web app. */ appBaseUrl: string; /** * Base URL of Reflag servers for SSE connections used by AutoFeedback. */ sseBaseUrl: string; /** * Whether to enable tracking. */ enableTracking: boolean; /** * Whether to enable offline mode. */ offline: boolean; /** * Whether the client is bootstrapped. */ bootstrapped: boolean; } /** * Toolbar options. */ export type ToolbarOptions = | boolean | { show?: boolean; position?: ToolbarPosition; }; /** * Flag definitions. */ export type FlagDefinitions = Readonly<Array<string>>; /** * ReflagClient initialization options. */ export type InitOptions = ReflagDeprecatedContext & { /** * Publishable key for authentication */ publishableKey: string; /** * You can provide a logger to see the logs of the network calls. * This is undefined by default. * For debugging purposes you can just set the browser console to this property: * ```javascript * options.logger = window.console; * ``` */ logger?: Logger; /** * Base URL of Reflag servers. You can override this to use your mocked server. */ apiBaseUrl?: string; /** * Base URL of the Reflag web app. Links open ín this app by default. */ appBaseUrl?: string; /** * Whether to enable offline mode. Defaults to `false`. */ offline?: boolean; /** * Flag keys for which `isEnabled` should fallback to true * if SDK fails to fetch flags from Reflag servers. If a record * is supplied instead of array, the values of each key represent the * configuration values and `isEnabled` is assume `true`. */ fallbackFlags?: string[] | Record<string, FallbackFlagOverride>; /** * Timeout in milliseconds when fetching flags */ timeoutMs?: number; /** * If set to true stale flags will be returned while refetching flags */ staleWhileRevalidate?: boolean; /** * If set, flags will be cached between page loads for this duration */ expireTimeMs?: number; /** * Stale flags will be returned if staleWhileRevalidate is true if no new flags can be fetched */ staleTimeMs?: number; /** * When proxying requests, you may want to include credentials like cookies * so you can authorize the request in the proxy. * This option controls the `credentials` option of the fetch API. */ credentials?: "include" | "same-origin" | "omit"; /** * Base URL of Reflag servers for SSE connections used by AutoFeedback. */ sseBaseUrl?: string; /** * AutoFeedback specific configuration */ feedback?: FeedbackOptions; /** * Version of the SDK */ sdkVersion?: string; /** * Whether to enable tracking. Defaults to `true`. */ enableTracking?: boolean; /** * Toolbar configuration */ toolbar?: ToolbarOptions; /** * Pre-fetched flags to be used instead of fetching them from the server. */ bootstrappedFlags?: RawFlags; }; const defaultConfig: Config = { apiBaseUrl: API_BASE_URL, appBaseUrl: APP_BASE_URL, sseBaseUrl: SSE_REALTIME_BASE_URL, enableTracking: true, offline: false, bootstrapped: false, }; /** * A remotely managed configuration value for a flag. */ export type FlagRemoteConfig = | { /** * The key of the matched configuration value. */ key: string; /** * The optional user-supplied payload data. */ payload: any; } | { key: undefined; payload: undefined }; /** * Represents a flag. */ export interface Flag { /** * Result of flag flag evaluation. * Note: Does not take local overrides into account. */ isEnabled: boolean; /* * Optional user-defined configuration. */ config: FlagRemoteConfig; /** * Function to send analytics events for this flag. */ track: () => Promise<Response | undefined>; /** * Function to request feedback for this flag. */ requestFeedback: ( options: Omit<RequestFeedbackData, "flagKey" | "featureId">, ) => void; /** * The current override status of isEnabled for the flag. */ isEnabledOverride: boolean | null; /** * Set the override status for isEnabled for the flag. * Set to `null` to remove the override. */ setIsEnabledOverride(isEnabled: boolean | null): void; } function shouldShowToolbar(opts: InitOptions) { const toolbarOpts = opts.toolbar; if (typeof window === "undefined") return false; if (typeof toolbarOpts === "boolean") return toolbarOpts; if (typeof toolbarOpts?.show === "boolean") return toolbarOpts.show; return window.location.hostname === "localhost"; } /** * ReflagClient lets you interact with the Reflag API. */ export class ReflagClient { private state: State = "idle"; private readonly publishableKey: string; private context: ReflagContext; private config: Config; private requestFeedbackOptions: Partial<RequestFeedbackOptions>; private readonly httpClient: HttpClient; private readonly autoFeedback: AutoFeedback | undefined; private autoFeedbackInit: Promise<void> | undefined; private readonly flagsClient: FlagsClient; public readonly logger: Logger; private readonly hooks: HooksManager; /** * Create a new ReflagClient instance. */ constructor(opts: InitOptions) { this.publishableKey = opts.publishableKey; this.logger = opts?.logger ?? loggerWithPrefix(quietConsoleLogger, "[Reflag]"); // Create the context object making sure to clone the user and company objects this.context = { user: opts?.user?.id ? { ...opts.user } : undefined, company: opts?.company?.id ? { ...opts.company } : undefined, other: { ...opts?.otherContext, ...opts?.other }, }; this.config = { apiBaseUrl: opts?.apiBaseUrl ?? defaultConfig.apiBaseUrl, appBaseUrl: opts?.appBaseUrl ?? defaultConfig.appBaseUrl, sseBaseUrl: opts?.sseBaseUrl ?? defaultConfig.sseBaseUrl, enableTracking: opts?.enableTracking ?? defaultConfig.enableTracking, offline: opts?.offline ?? defaultConfig.offline, bootstrapped: opts && "bootstrappedFlags" in opts && !!opts.bootstrappedFlags, }; this.requestFeedbackOptions = { position: opts?.feedback?.ui?.position, translations: opts?.feedback?.ui?.translations, }; this.httpClient = new HttpClient(this.publishableKey, { baseUrl: this.config.apiBaseUrl, sdkVersion: opts?.sdkVersion, credentials: opts?.credentials, }); this.flagsClient = new FlagsClient( this.httpClient, this.context, this.logger, { bootstrappedFlags: opts.bootstrappedFlags, expireTimeMs: opts.expireTimeMs, staleTimeMs: opts.staleTimeMs, staleWhileRevalidate: opts.staleWhileRevalidate, timeoutMs: opts.timeoutMs, fallbackFlags: opts.fallbackFlags, offline: this.config.offline, }, ); if ( !this.config.offline && this.context?.user && !isNode && // do not prompt on server-side opts?.feedback?.enableAutoFeedback !== false // default to on ) { if (isMobile) { this.logger.warn( "Feedback prompting is not supported on mobile devices", ); } else { this.autoFeedback = new AutoFeedback( this.config.sseBaseUrl, this.logger, this.httpClient, opts?.feedback?.autoFeedbackHandler, String(this.context.user?.id), opts?.feedback?.ui?.position, opts?.feedback?.ui?.translations, ); } } if (shouldShowToolbar(opts)) { this.logger.info("opening toolbar toggler"); showToolbarToggle({ reflagClient: this, position: typeof opts.toolbar === "object" ? opts.toolbar.position : undefined, }); } // Register hooks this.hooks = new HooksManager(); this.flagsClient.onUpdated(() => { this.hooks.trigger("flagsUpdated", this.flagsClient.getFlags()); }); } /** * Initialize the Reflag SDK. * * Must be called before calling other SDK methods. */ async initialize() { if (this.state === "initializing" || this.state === "initialized") { this.logger.warn(`"Reflag client already ${this.state}`); return; } this.setState("initializing"); const start = Date.now(); if (this.autoFeedback && !IS_SERVER) { // do not block on automated feedback surveys initialization this.autoFeedbackInit = this.autoFeedback.initialize().catch((e) => { this.logger.error("error initializing automated feedback surveys", e); }); } await this.flagsClient.initialize(); if (!this.config.bootstrapped) { if (this.context.user && this.config.enableTracking) { this.user().catch((e) => { this.logger.error("error sending user", e); }); } if (this.context.company && this.config.enableTracking) { this.company().catch((e) => { this.logger.error("error sending company", e); }); } } this.logger.info( "Reflag initialized in " + Math.round(Date.now() - start) + "ms" + (this.config.offline ? " (offline mode)" : ""), ); this.setState("initialized"); } /** * Stop the SDK. * This will stop any automated feedback surveys. * **/ async stop() { if (this.autoFeedback) { // ensure fully initialized before stopping await this.autoFeedbackInit; this.autoFeedback.stop(); } this.flagsClient.stop(); this.setState("stopped"); } getState() { return this.state; } /** * Add an event listener * * @param type Type of events to listen for * @param handler The function to call when the event is triggered. * @returns A function to remove the hook. */ on<THookType extends keyof HookArgs>( type: THookType, handler: (args0: HookArgs[THookType]) => void, ) { return this.hooks.addHook(type, handler); } /** * Remove an event listener * * @param type Type of event to remove. * @param handler The same function that was passed to `on`. * * @returns A function to remove the hook. */ off<THookType extends keyof HookArgs>( type: THookType, handler: (args0: HookArgs[THookType]) => void, ) { this.hooks.removeHook(type, handler); } /** * Get the current context. */ getContext() { return this.context; } /** * Get the current configuration. */ getConfig() { return this.config; } /** * Update the user context. * Performs a shallow merge with the existing user context. * It will not update the context if nothing has changed. * * @param user */ async updateUser(user: { [key: string]: string | number | undefined }) { const userIdChanged = user.id && user.id !== this.context.user?.id; const newUserContext = { ...this.context.user, ...user, id: user.id ?? this.context.user?.id, }; // Nothing has changed, skipping update if (deepEqual(this.context.user, newUserContext)) return; this.context.user = newUserContext; void this.user(); // Update the feedback user if the user ID has changed if (userIdChanged) { void this.updateAutoFeedbackUser(String(user.id)); } await this.flagsClient.setContext(this.context); } /** * Update the company context. * Performs a shallow merge with the existing company context. * It will not update the context if nothing has changed. * * @param company The company details. */ async updateCompany(company: { [key: string]: string | number | undefined }) { const newCompanyContext = { ...this.context.company, ...company, id: company.id ?? this.context.company?.id, }; // Nothing has changed, skipping update if (deepEqual(this.context.company, newCompanyContext)) return; this.context.company = newCompanyContext; void this.company(); await this.flagsClient.setContext(this.context); } /** * Update the company context. * Performs a shallow merge with the existing company context. * It will not update the context if nothing has changed. * * @param otherContext Additional context. */ async updateOtherContext(otherContext: { [key: string]: string | number | undefined; }) { const newOtherContext = { ...this.context.other, ...otherContext, }; // Nothing has changed, skipping update if (deepEqual(this.context.other, newOtherContext)) return; this.context.other = newOtherContext; await this.flagsClient.setContext(this.context); } /** * Update the context. * Replaces the existing context with a new context. * * @param context The context to update. */ async setContext({ otherContext, ...context }: ReflagDeprecatedContext) { const userIdChanged = context.user?.id && context.user.id !== this.context.user?.id; // Create a new context object making sure to clone the user and company objects const newContext = { user: context.user?.id ? { ...context.user } : undefined, company: context.company?.id ? { ...context.company } : undefined, other: { ...otherContext, ...context.other }, }; if (!context.user?.id) { this.logger.warn("No user Id provided in context, user will be ignored"); } if (!context.company?.id) { this.logger.warn( "No company Id provided in context, company will be ignored", ); } // Nothing has changed, skipping update if (deepEqual(this.context, newContext)) return; this.context = newContext; if (context.company) { void this.company(); } if (context.user) { void this.user(); // Update the automatic feedback user if the user ID has changed if (userIdChanged) { void this.updateAutoFeedbackUser(String(context.user.id)); } } await this.flagsClient.setContext(this.context); } /** * Update the flags. * * @param flags The flags to update. * @param triggerEvent Whether to trigger the `flagsUpdated` event. */ updateFlags(flags: RawFlags, triggerEvent = true) { this.flagsClient.setFetchedFlags(flags, triggerEvent); } /** * Track an event in Reflag. * * @param eventName The name of the event. * @param attributes Any attributes you want to attach to the event. */ async track(eventName: string, attributes?: Record<string, any> | null) { if (!this.context.user) { this.logger.warn("'track' call ignored. No user context provided"); return; } if (!this.config.enableTracking) { this.logger.warn("'track' call ignored. 'enableTracking' is false"); return; } if (this.config.offline) { return; } const payload: TrackedEvent = { userId: String(this.context.user.id), event: eventName, }; if (attributes) payload.attributes = attributes; if (this.context.company?.id) payload.companyId = String(this.context.company?.id); const res = await this.httpClient.post({ path: `/event`, body: payload }); this.logger.debug(`sent event`, res); this.hooks.trigger("track", { eventName, attributes, user: this.context.user, company: this.context.company, }); return res; } /** * Submit user feedback to Reflag. Must include either `score` or `comment`, or both. * * @param payload The feedback details to submit. * @returns The server response. */ async feedback(payload: Feedback) { if (this.config.offline) { return; } const userId = payload.userId || (this.context.user?.id ? String(this.context.user?.id) : undefined); const companyId = payload.companyId || (this.context.company?.id ? String(this.context.company?.id) : undefined); return await feedback(this.httpClient, this.logger, { userId, companyId, ...payload, }); } /** * Display the Reflag feedback form UI programmatically. * * This can be used to collect feedback from users in Reflag in cases where Automated Feedback Surveys isn't appropriate. * * @param options */ requestFeedback(options: RequestFeedbackData) { if (!this.context.user?.id) { this.logger.error( "`requestFeedback` call ignored. No `user` context provided at initialization", ); return; } if (!options.flagKey) { this.logger.error( "`requestFeedback` call ignored. No `flagKey` provided", ); return; } const feedbackData = { flagKey: options.flagKey, companyId: options.companyId || (this.context.company?.id ? String(this.context.company?.id) : undefined), source: "widget" as const, } satisfies Feedback; // Wait a tick before opening the feedback form, // to prevent the same click from closing it. setTimeout(() => { feedbackLib.openFeedbackForm({ key: options.flagKey, title: options.title, position: options.position || this.requestFeedbackOptions.position, translations: options.translations || this.requestFeedbackOptions.translations, openWithCommentVisible: options.openWithCommentVisible, onClose: options.onClose, onDismiss: options.onDismiss, onScoreSubmit: async (data) => { const res = await this.feedback({ ...feedbackData, ...data, }); if (res) { const json = await res.json(); return { feedbackId: json.feedbackId }; } return { feedbackId: undefined }; }, onSubmit: async (data) => { // Default onSubmit handler await this.feedback({ ...feedbackData, ...data, }); options.onAfterSubmit?.(data); }, }); }, 1); } /** * @deprecated Use `getFlags` instead. */ getFeatures() { return this.getFlags(); } /** * Returns a map of enabled flags. * Accessing a flag will *not* send a check event * and `isEnabled` does not take any flag overrides * into account. * * @returns Map of flags. */ getFlags(): RawFlags { return this.flagsClient.getFlags(); } /** * @deprecated Use `getFlag` instead. */ getFeature(flagKey: string) { return this.getFlag(flagKey); } /** * Return a flag. Accessing `isEnabled` or `config` will automatically send a `check` event. * * @param flagKey - The key of the flag to get. * @returns A flag. */ getFlag(flagKey: string): Flag { const f = this.getFlags()[flagKey]; // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; const value = f?.isEnabledOverride ?? f?.isEnabled ?? false; const config = f?.config ? { key: f.config.key, payload: f.config.payload, } : { key: undefined, payload: undefined }; return { get isEnabled() { self .sendCheckEvent({ action: "check-is-enabled", key: flagKey, version: f?.targetingVersion, ruleEvaluationResults: f?.ruleEvaluationResults, missingContextFields: f?.missingContextFields, value, }) .catch(() => { // ignore }); return value; }, get config() { self .sendCheckEvent({ action: "check-config", key: flagKey, version: f?.config?.version, ruleEvaluationResults: f?.config?.ruleEvaluationResults, missingContextFields: f?.config?.missingContextFields, value: f?.config && { key: f.config.key, payload: f.config.payload, }, }) .catch(() => { // ignore }); return config; }, track: () => this.track(flagKey), requestFeedback: ( options: Omit<RequestFeedbackData, "flagKey" | "featureId">, ) => { this.requestFeedback({ flagKey, ...options, }); }, isEnabledOverride: this.flagsClient.getFlagOverride(flagKey), setIsEnabledOverride(isEnabled: boolean | null) { self.flagsClient.setFlagOverride(flagKey, isEnabled); }, }; } private setState(state: State) { this.state = state; this.hooks.trigger("stateUpdated", state); } private sendCheckEvent(checkEvent: CheckEvent) { return this.flagsClient.sendCheckEvent(checkEvent, () => { this.hooks.trigger("check", checkEvent); }); } /** * Send attributes to Reflag for the current user */ private async user() { if (!this.context.user) { this.logger.warn( "`user` call ignored. No user context provided at initialization", ); return; } if (this.config.offline) { return; } const { id, ...attributes } = this.context.user; const payload: User = { userId: String(id), attributes, }; const res = await this.httpClient.post({ path: `/user`, body: payload }); this.logger.debug(`sent user`, res); this.hooks.trigger("user", this.context.user); return res; } /** * Send attributes to Reflag for the current company. */ private async company() { if (!this.context.user) { this.logger.warn( "`company` call ignored. No user context provided at initialization", ); return; } if (!this.context.company) { this.logger.warn( "`company` call ignored. No company context provided at initialization", ); return; } if (this.config.offline) { return; } const { id, ...attributes } = this.context.company; const payload: Company = { userId: String(this.context.user.id), companyId: String(id), attributes, }; const res = await this.httpClient.post({ path: `/company`, body: payload }); this.logger.debug(`sent company`, res); this.hooks.trigger("company", this.context.company); return res; } private async updateAutoFeedbackUser(userId: string) { if (!this.autoFeedback) { return; } // Ensure fully initialized before updating the user await this.autoFeedbackInit; await this.autoFeedback.setUser(userId); } }

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