This is page 8 of 9. Use http://codebase.md/bucketco/bucket-javascript-sdk?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .editorconfig
├── .gitattributes
├── .github
│ └── workflows
│ ├── package-ci.yml
│ └── publish.yml
├── .gitignore
├── .nvmrc
├── .prettierignore
├── .vscode
│ ├── extensions.json
│ └── settings.json
├── .yarnrc.yml
├── docs.sh
├── lerna.json
├── LICENSE
├── package.json
├── packages
│ ├── browser-sdk
│ │ ├── .prettierignore
│ │ ├── eslint.config.js
│ │ ├── example
│ │ │ ├── feedback
│ │ │ │ ├── feedback.html
│ │ │ │ └── Feedback.jsx
│ │ │ └── typescript
│ │ │ ├── app.ts
│ │ │ └── index.html
│ │ ├── FEEDBACK.md
│ │ ├── index.html
│ │ ├── package.json
│ │ ├── playwright.config.ts
│ │ ├── postcss.config.js
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── client.ts
│ │ │ ├── config.ts
│ │ │ ├── context.ts
│ │ │ ├── feedback
│ │ │ │ ├── feedback.ts
│ │ │ │ ├── prompts.ts
│ │ │ │ ├── promptStorage.ts
│ │ │ │ └── ui
│ │ │ │ ├── Button.css
│ │ │ │ ├── Button.tsx
│ │ │ │ ├── config
│ │ │ │ │ └── defaultTranslations.tsx
│ │ │ │ ├── css.d.ts
│ │ │ │ ├── FeedbackDialog.css
│ │ │ │ ├── FeedbackDialog.tsx
│ │ │ │ ├── FeedbackForm.css
│ │ │ │ ├── FeedbackForm.tsx
│ │ │ │ ├── hooks
│ │ │ │ │ └── useTimer.ts
│ │ │ │ ├── index.css
│ │ │ │ ├── index.ts
│ │ │ │ ├── Plug.tsx
│ │ │ │ ├── RadialProgress.css
│ │ │ │ ├── RadialProgress.tsx
│ │ │ │ ├── StarRating.css
│ │ │ │ ├── StarRating.tsx
│ │ │ │ └── types.ts
│ │ │ ├── flag
│ │ │ │ ├── flagCache.ts
│ │ │ │ └── flags.ts
│ │ │ ├── hooksManager.ts
│ │ │ ├── httpClient.ts
│ │ │ ├── index.ts
│ │ │ ├── logger.ts
│ │ │ ├── rateLimiter.ts
│ │ │ ├── sse.ts
│ │ │ ├── toolbar
│ │ │ │ ├── Flags.css
│ │ │ │ ├── Flags.tsx
│ │ │ │ ├── index.css
│ │ │ │ ├── index.ts
│ │ │ │ ├── Switch.css
│ │ │ │ ├── Switch.tsx
│ │ │ │ ├── Toolbar.css
│ │ │ │ └── Toolbar.tsx
│ │ │ └── ui
│ │ │ ├── constants.ts
│ │ │ ├── Dialog.css
│ │ │ ├── Dialog.tsx
│ │ │ ├── icons
│ │ │ │ ├── Check.tsx
│ │ │ │ ├── CheckCircle.tsx
│ │ │ │ ├── Close.tsx
│ │ │ │ ├── Dissatisfied.tsx
│ │ │ │ ├── Logo.tsx
│ │ │ │ ├── Neutral.tsx
│ │ │ │ ├── Satisfied.tsx
│ │ │ │ ├── VeryDissatisfied.tsx
│ │ │ │ └── VerySatisfied.tsx
│ │ │ ├── packages
│ │ │ │ └── floating-ui-preact-dom
│ │ │ │ ├── arrow.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── README.md
│ │ │ │ ├── types.ts
│ │ │ │ ├── useFloating.ts
│ │ │ │ └── utils
│ │ │ │ ├── deepEqual.ts
│ │ │ │ ├── getDPR.ts
│ │ │ │ ├── roundByDPR.ts
│ │ │ │ └── useLatestRef.ts
│ │ │ ├── types.ts
│ │ │ └── utils.ts
│ │ ├── test
│ │ │ ├── client.test.ts
│ │ │ ├── e2e
│ │ │ │ ├── acceptance.browser.spec.ts
│ │ │ │ ├── empty.html
│ │ │ │ ├── feedback-widget.browser.spec.ts
│ │ │ │ └── give-feedback-button.html
│ │ │ ├── flagCache.test.ts
│ │ │ ├── flags.test.ts
│ │ │ ├── hooksManager.test.ts
│ │ │ ├── httpClient.test.ts
│ │ │ ├── init.test.ts
│ │ │ ├── mocks
│ │ │ │ ├── handlers.ts
│ │ │ │ └── server.ts
│ │ │ ├── prompts.test.ts
│ │ │ ├── promptStorage.test.ts
│ │ │ ├── rateLimiter.test.ts
│ │ │ ├── sse.test.ts
│ │ │ ├── testLogger.ts
│ │ │ └── usage.test.ts
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ ├── typedoc.json
│ │ ├── vite.config.mjs
│ │ ├── vite.e2e.config.js
│ │ └── vitest.setup.ts
│ ├── cli
│ │ ├── .prettierignore
│ │ ├── commands
│ │ │ ├── apps.ts
│ │ │ ├── auth.ts
│ │ │ ├── flags.ts
│ │ │ ├── init.ts
│ │ │ ├── mcp.ts
│ │ │ ├── new.ts
│ │ │ └── rules.ts
│ │ ├── eslint.config.js
│ │ ├── index.ts
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── schema.json
│ │ ├── services
│ │ │ ├── bootstrap.ts
│ │ │ ├── flags.ts
│ │ │ ├── mcp.ts
│ │ │ └── rules.ts
│ │ ├── stores
│ │ │ ├── auth.ts
│ │ │ └── config.ts
│ │ ├── test
│ │ │ └── json.test.ts
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ ├── utils
│ │ │ ├── auth.ts
│ │ │ ├── commander.ts
│ │ │ ├── constants.ts
│ │ │ ├── errors.ts
│ │ │ ├── file.ts
│ │ │ ├── gen.ts
│ │ │ ├── json.ts
│ │ │ ├── options.ts
│ │ │ ├── schemas.ts
│ │ │ ├── types.ts
│ │ │ ├── urls.ts
│ │ │ └── version.ts
│ │ └── vite.config.js
│ ├── eslint-config
│ │ ├── base.js
│ │ └── package.json
│ ├── flag-evaluation
│ │ ├── .prettierignore
│ │ ├── eslint.config.js
│ │ ├── jest.config.js
│ │ ├── package.json
│ │ ├── src
│ │ │ └── index.ts
│ │ ├── test
│ │ │ └── index.test.ts
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ └── tsconfig.json
│ ├── node-sdk
│ │ ├── .prettierignore
│ │ ├── docs
│ │ │ ├── type-check-failed.png
│ │ │ └── type-check-payload-failed.png
│ │ ├── eslint.config.js
│ │ ├── examples
│ │ │ ├── cloudflare-worker
│ │ │ │ ├── .gitignore
│ │ │ │ ├── .prettierignore
│ │ │ │ ├── .vscode
│ │ │ │ │ └── settings.json
│ │ │ │ ├── package.json
│ │ │ │ ├── README.md
│ │ │ │ ├── src
│ │ │ │ │ └── index.ts
│ │ │ │ ├── tsconfig.json
│ │ │ │ ├── vitest.config.mts
│ │ │ │ ├── worker-configuration.d.ts
│ │ │ │ ├── wrangler.jsonc
│ │ │ │ └── yarn.lock
│ │ │ └── express
│ │ │ ├── app.test.ts
│ │ │ ├── app.ts
│ │ │ ├── bucket.ts
│ │ │ ├── bucketConfig.json
│ │ │ ├── package.json
│ │ │ ├── README.md
│ │ │ ├── serve.ts
│ │ │ ├── tsconfig.json
│ │ │ └── yarn.lock
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── batch-buffer.ts
│ │ │ ├── client.ts
│ │ │ ├── config.ts
│ │ │ ├── edgeClient.ts
│ │ │ ├── fetch-http-client.ts
│ │ │ ├── flusher.ts
│ │ │ ├── index.ts
│ │ │ ├── inRequestCache.ts
│ │ │ ├── periodicallyUpdatingCache.ts
│ │ │ ├── rate-limiter.ts
│ │ │ ├── types.ts
│ │ │ └── utils.ts
│ │ ├── test
│ │ │ ├── batch-buffer.test.ts
│ │ │ ├── client.test.ts
│ │ │ ├── config.test.ts
│ │ │ ├── fetch-http-client.test.ts
│ │ │ ├── flusher.test.ts
│ │ │ ├── inRequestCache.test.ts
│ │ │ ├── periodicallyUpdatingCache.test.ts
│ │ │ ├── rate-limiter.test.ts
│ │ │ ├── testConfig.json
│ │ │ └── utils.test.ts
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ ├── typedoc.json
│ │ └── vite.config.js
│ ├── openfeature-browser-provider
│ │ ├── .prettierignore
│ │ ├── eslint.config.js
│ │ ├── example
│ │ │ ├── .eslintrc.json
│ │ │ ├── .gitignore
│ │ │ ├── app
│ │ │ │ ├── featureManagement.ts
│ │ │ │ ├── globals.css
│ │ │ │ ├── layout.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── components
│ │ │ │ ├── Context.tsx
│ │ │ │ ├── HuddleFeature.tsx
│ │ │ │ └── OpenFeatureProvider.tsx
│ │ │ ├── next.config.mjs
│ │ │ ├── package.json
│ │ │ ├── postcss.config.mjs
│ │ │ ├── README.md
│ │ │ ├── tailwind.config.ts
│ │ │ └── tsconfig.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── index.test.ts
│ │ │ └── index.ts
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ └── vite.config.js
│ ├── openfeature-node-provider
│ │ ├── .prettierignore
│ │ ├── eslint.config.js
│ │ ├── example
│ │ │ ├── app.ts
│ │ │ ├── package.json
│ │ │ ├── README.md
│ │ │ ├── reflag.ts
│ │ │ ├── serve.ts
│ │ │ ├── tsconfig.json
│ │ │ └── yarn.lock
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── index.test.ts
│ │ │ └── index.ts
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ └── vite.config.js
│ ├── react-sdk
│ │ ├── .prettierignore
│ │ ├── dev
│ │ │ ├── .env
│ │ │ ├── nextjs-bootstrap-demo
│ │ │ │ ├── .eslintrc.json
│ │ │ │ ├── .gitignore
│ │ │ │ ├── app
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── favicon.ico
│ │ │ │ │ ├── globals.css
│ │ │ │ │ ├── layout.tsx
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── components
│ │ │ │ │ └── Flags.tsx
│ │ │ │ ├── next.config.mjs
│ │ │ │ ├── package.json
│ │ │ │ ├── postcss.config.mjs
│ │ │ │ ├── public
│ │ │ │ │ ├── next.svg
│ │ │ │ │ └── vercel.svg
│ │ │ │ ├── README.md
│ │ │ │ ├── tailwind.config.ts
│ │ │ │ └── tsconfig.json
│ │ │ ├── nextjs-flag-demo
│ │ │ │ ├── .eslintrc.json
│ │ │ │ ├── .gitignore
│ │ │ │ ├── app
│ │ │ │ │ ├── favicon.ico
│ │ │ │ │ ├── globals.css
│ │ │ │ │ ├── layout.tsx
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── components
│ │ │ │ │ ├── Flags.tsx
│ │ │ │ │ └── Providers.tsx
│ │ │ │ ├── next.config.mjs
│ │ │ │ ├── package.json
│ │ │ │ ├── postcss.config.mjs
│ │ │ │ ├── public
│ │ │ │ │ ├── next.svg
│ │ │ │ │ └── vercel.svg
│ │ │ │ ├── README.md
│ │ │ │ ├── tailwind.config.ts
│ │ │ │ └── tsconfig.json
│ │ │ └── plain
│ │ │ ├── app.tsx
│ │ │ ├── index.html
│ │ │ ├── index.tsx
│ │ │ ├── tsconfig.json
│ │ │ └── vite-env.d.ts
│ │ ├── eslint.config.js
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ └── index.tsx
│ │ ├── test
│ │ │ └── usage.test.tsx
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ ├── typedoc.json
│ │ └── vite.config.mjs
│ ├── tsconfig
│ │ ├── library.json
│ │ └── package.json
│ └── vue-sdk
│ ├── .prettierignore
│ ├── dev
│ │ └── plain
│ │ ├── App.vue
│ │ ├── components
│ │ │ ├── Events.vue
│ │ │ ├── FlagsList.vue
│ │ │ ├── MissingKeyMessage.vue
│ │ │ ├── RequestFeedback.vue
│ │ │ ├── Section.vue
│ │ │ ├── StartHuddlesButton.vue
│ │ │ └── Track.vue
│ │ ├── env.d.ts
│ │ ├── index.html
│ │ └── index.ts
│ ├── eslint.config.js
│ ├── package.json
│ ├── README.md
│ ├── src
│ │ ├── hooks.ts
│ │ ├── index.ts
│ │ ├── ReflagBootstrappedProvider.vue
│ │ ├── ReflagClientProvider.vue
│ │ ├── ReflagProvider.vue
│ │ ├── types.ts
│ │ ├── version.ts
│ │ └── vue.d.ts
│ ├── test
│ │ └── usage.test.ts
│ ├── tsconfig.build.json
│ ├── tsconfig.eslint.json
│ ├── tsconfig.json
│ ├── typedoc.json
│ └── vite.config.mjs
├── README.md
├── typedoc.json
├── vitest.workspace.js
└── yarn.lock
```
# Files
--------------------------------------------------------------------------------
/packages/node-sdk/src/client.ts:
--------------------------------------------------------------------------------
```typescript
1 | import fs from "fs";
2 |
3 | import {
4 | EvaluationResult,
5 | flattenJSON,
6 | newEvaluator,
7 | } from "@reflag/flag-evaluation";
8 |
9 | import BatchBuffer from "./batch-buffer";
10 | import {
11 | API_BASE_URL,
12 | API_TIMEOUT_MS,
13 | FLAG_EVENT_RATE_LIMITER_WINDOW_SIZE_MS,
14 | FLAGS_REFETCH_MS,
15 | loadConfig,
16 | REFLAG_LOG_PREFIX,
17 | SDK_VERSION,
18 | SDK_VERSION_HEADER_NAME,
19 | } from "./config";
20 | import fetchClient, { withRetry } from "./fetch-http-client";
21 | import { subscribe as triggerOnExit } from "./flusher";
22 | import inRequestCache from "./inRequestCache";
23 | import periodicallyUpdatingCache from "./periodicallyUpdatingCache";
24 | import { newRateLimiter } from "./rate-limiter";
25 | import type {
26 | BootstrappedFlags,
27 | CachedFlagDefinition,
28 | CacheStrategy,
29 | EvaluatedFlagsAPIResponse,
30 | FlagDefinition,
31 | FlagOverrides,
32 | FlagOverridesFn,
33 | IdType,
34 | RawFlag,
35 | RawFlags,
36 | TypedFlagKey,
37 | } from "./types";
38 | import {
39 | Attributes,
40 | Cache,
41 | ClientOptions,
42 | Context,
43 | ContextWithTracking,
44 | FlagEvent,
45 | FlagsAPIResponse,
46 | HttpClient,
47 | Logger,
48 | TrackingMeta,
49 | TrackOptions,
50 | TypedFlags,
51 | } from "./types";
52 | import {
53 | applyLogLevel,
54 | decorateLogger,
55 | hashObject,
56 | idOk,
57 | isObject,
58 | mergeSkipUndefined,
59 | ok,
60 | once,
61 | } from "./utils";
62 |
63 | const reflagConfigDefaultFile = "reflag.config.json";
64 |
65 | type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
66 |
67 | type BulkEvent =
68 | | {
69 | type: "company";
70 | companyId: IdType;
71 | userId?: IdType;
72 | attributes?: Attributes;
73 | context?: TrackingMeta;
74 | }
75 | | {
76 | type: "user";
77 | userId: IdType;
78 | attributes?: Attributes;
79 | context?: TrackingMeta;
80 | }
81 | | {
82 | type: "feature-flag-event";
83 | action: "check" | "check-config";
84 | key: string;
85 | targetingVersion?: number;
86 | evalResult:
87 | | boolean
88 | | { key: string; payload: any }
89 | | { key: undefined; payload: undefined };
90 | evalContext?: Record<string, any>;
91 | evalRuleResults?: boolean[];
92 | evalMissingFields?: string[];
93 | }
94 | | {
95 | type: "event";
96 | event: string;
97 | companyId?: IdType;
98 | userId: IdType;
99 | attributes?: Attributes;
100 | context?: TrackingMeta;
101 | };
102 |
103 | /**
104 | * The SDK client.
105 | *
106 | * @remarks
107 | * This is the main class for interacting with Reflag.
108 | * It is used to evaluate flags, update user and company contexts, and track events.
109 | *
110 | * @example
111 | * ```ts
112 | * // set the REFLAG_SECRET_KEY environment variable or pass the secret key to the constructor
113 | * const client = new ReflagClient();
114 | *
115 | * // evaluate a flag
116 | * const isFlagEnabled = client.getFlag("flag-key", {
117 | * user: { id: "user-id" },
118 | * company: { id: "company-id" },
119 | * });
120 | * ```
121 | **/
122 | export class ReflagClient {
123 | private _config: {
124 | apiBaseUrl: string;
125 | refetchInterval: number;
126 | staleWarningInterval: number;
127 | headers: Record<string, string>;
128 | fallbackFlags?: RawFlags;
129 | flagOverrides: FlagOverridesFn;
130 | offline: boolean;
131 | configFile?: string;
132 | flagsFetchRetries: number;
133 | fetchTimeoutMs: number;
134 | cacheStrategy: CacheStrategy;
135 | };
136 | httpClient: HttpClient;
137 |
138 | private flagsCache: Cache<CachedFlagDefinition[]>;
139 | private batchBuffer: BatchBuffer<BulkEvent>;
140 | private rateLimiter: ReturnType<typeof newRateLimiter>;
141 |
142 | /**
143 | * Gets the logger associated with the client.
144 | */
145 | public readonly logger: Logger;
146 |
147 | private initializationFinished = false;
148 | private _initialize = once(async () => {
149 | const start = Date.now();
150 | if (!this._config.offline) {
151 | await this.flagsCache.refresh();
152 | }
153 | this.logger.info(
154 | "Reflag initialized in " +
155 | Math.round(Date.now() - start) +
156 | "ms" +
157 | (this._config.offline ? " (offline mode)" : ""),
158 | );
159 | this.initializationFinished = true;
160 | });
161 |
162 | /**
163 | * Creates a new SDK client.
164 | * See README for configuration options.
165 | *
166 | * @param options - The options for the client or an existing client to clone.
167 | * @param options.secretKey - The secret key to use for the client.
168 | * @param options.apiBaseUrl - The base URL to send requests to (optional).
169 | * @param options.logger - The logger to use for logging (optional).
170 | * @param options.httpClient - The HTTP client to use for sending requests (optional).
171 | * @param options.logLevel - The log level to use for logging (optional).
172 | * @param options.offline - Whether to run in offline mode (optional).
173 | * @param options.fallbackFlags - The fallback flags to use if the flag is not found (optional).
174 | * @param options.batchOptions - The options for the batch buffer (optional).
175 | * @param options.flagOverrides - The flag overrides to use for the client (optional).
176 | * @param options.configFile - The path to the config file (optional).
177 | * @param options.flagsFetchRetries - Number of retries for fetching flags (optional, defaults to 3).
178 | * @param options.fetchTimeoutMs - Timeout for fetching flags (optional, defaults to 10000ms).
179 | * @param options.cacheStrategy - The cache strategy to use for the client (optional, defaults to "periodically-update").
180 | *
181 | * @throws An error if the options are invalid.
182 | **/
183 | constructor(options: ClientOptions = {}) {
184 | ok(isObject(options), "options must be an object");
185 |
186 | ok(
187 | options.host === undefined ||
188 | (typeof options.host === "string" && options.host.length > 0),
189 | "host must be a string",
190 | );
191 | ok(
192 | options.apiBaseUrl === undefined ||
193 | (typeof options.apiBaseUrl === "string" &&
194 | options.apiBaseUrl.length > 0),
195 | "apiBaseUrl must be a string",
196 | );
197 | ok(
198 | options.logger === undefined || isObject(options.logger),
199 | "logger must be an object",
200 | );
201 | ok(
202 | options.httpClient === undefined || isObject(options.httpClient),
203 | "httpClient must be an object",
204 | );
205 | ok(
206 | options.fallbackFlags === undefined ||
207 | Array.isArray(options.fallbackFlags) ||
208 | isObject(options.fallbackFlags),
209 | "fallbackFlags must be an array or object",
210 | );
211 | ok(
212 | options.batchOptions === undefined || isObject(options.batchOptions),
213 | "batchOptions must be an object",
214 | );
215 | ok(
216 | options.configFile === undefined ||
217 | typeof options.configFile === "string",
218 | "configFile must be a string",
219 | );
220 |
221 | ok(
222 | options.flagsFetchRetries === undefined ||
223 | (Number.isInteger(options.flagsFetchRetries) &&
224 | options.flagsFetchRetries >= 0),
225 | "flagsFetchRetries must be a non-negative integer",
226 | );
227 |
228 | ok(
229 | options.fetchTimeoutMs === undefined ||
230 | (Number.isInteger(options.fetchTimeoutMs) &&
231 | options.fetchTimeoutMs >= 0),
232 | "fetchTimeoutMs must be a non-negative integer",
233 | );
234 |
235 | if (!options.configFile) {
236 | options.configFile =
237 | (process.env.REFLAG_CONFIG_FILE ??
238 | fs.existsSync(reflagConfigDefaultFile))
239 | ? reflagConfigDefaultFile
240 | : undefined;
241 | }
242 |
243 | const externalConfig = loadConfig(options.configFile);
244 | const config = mergeSkipUndefined(externalConfig, options);
245 |
246 | const offline = config.offline ?? process.env.NODE_ENV === "test";
247 | if (!offline) {
248 | ok(
249 | typeof config.secretKey === "string",
250 | "secretKey must be a string, or set offline=true",
251 | );
252 | ok(config.secretKey.length > 22, "invalid secretKey specified");
253 | }
254 |
255 | // use the supplied logger or apply the log level to the console logger
256 | const logLevel = options.logLevel ?? config?.logLevel ?? "INFO";
257 |
258 | this.logger = options.logger
259 | ? options.logger
260 | : applyLogLevel(decorateLogger(REFLAG_LOG_PREFIX, console), logLevel);
261 |
262 | const fallbackFlags = Array.isArray(options.fallbackFlags)
263 | ? options.fallbackFlags.reduce((acc, key) => {
264 | acc[key as TypedFlagKey] = {
265 | isEnabled: true,
266 | key,
267 | };
268 | return acc;
269 | }, {} as RawFlags)
270 | : isObject(options.fallbackFlags)
271 | ? Object.entries(options.fallbackFlags).reduce(
272 | (acc, [key, fallback]) => {
273 | acc[key as TypedFlagKey] = {
274 | isEnabled:
275 | typeof fallback === "object"
276 | ? fallback.isEnabled
277 | : !!fallback,
278 | key,
279 | config:
280 | typeof fallback === "object" && fallback.config
281 | ? {
282 | key: fallback.config.key,
283 | payload: fallback.config.payload,
284 | }
285 | : undefined,
286 | };
287 | return acc;
288 | },
289 | {} as RawFlags,
290 | )
291 | : undefined;
292 |
293 | this.rateLimiter = newRateLimiter(FLAG_EVENT_RATE_LIMITER_WINDOW_SIZE_MS);
294 | this.httpClient = options.httpClient || fetchClient;
295 | this.batchBuffer = new BatchBuffer<BulkEvent>({
296 | ...options?.batchOptions,
297 | flushHandler: (items) => this.sendBulkEvents(items),
298 | logger: this.logger,
299 | });
300 |
301 | this._config = {
302 | offline,
303 | apiBaseUrl: (config.apiBaseUrl ?? config.host) || API_BASE_URL,
304 | headers: {
305 | "Content-Type": "application/json",
306 | [SDK_VERSION_HEADER_NAME]: SDK_VERSION,
307 | ["Authorization"]: `Bearer ${config.secretKey}`,
308 | },
309 | refetchInterval: FLAGS_REFETCH_MS,
310 | staleWarningInterval: FLAGS_REFETCH_MS * 5,
311 | fallbackFlags: fallbackFlags,
312 | flagOverrides:
313 | typeof config.flagOverrides === "function"
314 | ? config.flagOverrides
315 | : () => config.flagOverrides,
316 | flagsFetchRetries: options.flagsFetchRetries ?? 3,
317 | fetchTimeoutMs: options.fetchTimeoutMs ?? API_TIMEOUT_MS,
318 | cacheStrategy: options.cacheStrategy ?? "periodically-update",
319 | };
320 |
321 | if ((config.batchOptions?.flushOnExit ?? true) && !this._config.offline) {
322 | triggerOnExit(() => this.flush());
323 | }
324 |
325 | if (!new URL(this._config.apiBaseUrl).pathname.endsWith("/")) {
326 | this._config.apiBaseUrl += "/";
327 | }
328 |
329 | const fetchFlags = async () => {
330 | const res = await this.get<FlagsAPIResponse>(
331 | "features",
332 | this._config.flagsFetchRetries,
333 | );
334 | if (!isObject(res) || !Array.isArray(res?.features)) {
335 | this.logger.warn("flags cache: invalid response", res);
336 | return undefined;
337 | }
338 |
339 | return res.features.map((flagDef) => {
340 | return {
341 | ...flagDef,
342 | enabledEvaluator: newEvaluator(
343 | flagDef.targeting.rules.map((rule) => ({
344 | filter: rule.filter,
345 | value: true,
346 | })),
347 | ),
348 | configEvaluator: flagDef.config
349 | ? newEvaluator(
350 | flagDef.config?.variants.map((variant) => ({
351 | filter: variant.filter,
352 | value: {
353 | key: variant.key,
354 | payload: variant.payload,
355 | },
356 | })),
357 | )
358 | : undefined,
359 | } satisfies CachedFlagDefinition;
360 | });
361 | };
362 |
363 | if (this._config.cacheStrategy === "periodically-update") {
364 | this.flagsCache = periodicallyUpdatingCache<CachedFlagDefinition[]>(
365 | this._config.refetchInterval,
366 | this._config.staleWarningInterval,
367 | this.logger,
368 | fetchFlags,
369 | );
370 | } else {
371 | this.flagsCache = inRequestCache<CachedFlagDefinition[]>(
372 | this._config.refetchInterval,
373 | this.logger,
374 | fetchFlags,
375 | );
376 | }
377 | }
378 |
379 | /**
380 | * Sets the flag overrides.
381 | *
382 | * @param overrides - The flag overrides.
383 | *
384 | * @remarks
385 | * The flag overrides are used to override the flag definitions.
386 | * This is useful for testing or development.
387 | *
388 | * @example
389 | * ```ts
390 | * client.flagOverrides = {
391 | * "flag-1": true,
392 | * "flag-2": false,
393 | * };
394 | * ```
395 | **/
396 | set flagOverrides(overrides: FlagOverridesFn | FlagOverrides) {
397 | if (typeof overrides === "object") {
398 | this._config.flagOverrides = () => overrides;
399 | } else {
400 | this._config.flagOverrides = overrides;
401 | }
402 | }
403 |
404 | /**
405 | * Clears the flag overrides.
406 | *
407 | * @remarks
408 | * This is useful for testing or development.
409 | *
410 | * @example
411 | * ```ts
412 | * afterAll(() => {
413 | * client.clearFlagOverrides();
414 | * });
415 | * ```
416 | **/
417 | clearFlagOverrides() {
418 | this._config.flagOverrides = () => ({});
419 | }
420 |
421 | /**
422 | * Returns a new BoundReflagClient with the user/company/otherContext
423 | * set to be used in subsequent calls.
424 | * For example, for evaluating flag targeting or tracking events.
425 | *
426 | * @param context - The context to bind the client to.
427 | * @param context.enableTracking - Whether to enable tracking for the context.
428 | * @param context.user - The user context.
429 | * @param context.company - The company context.
430 | * @param context.other - The other context.
431 | *
432 | * @returns A new client bound with the arguments given.
433 | *
434 | * @throws An error if the user/company is given but their ID is not a string.
435 | *
436 | * @remarks
437 | * The `updateUser` / `updateCompany` methods will automatically be called when
438 | * the user/company is set respectively.
439 | **/
440 | public bindClient({
441 | enableTracking = true,
442 | ...context
443 | }: ContextWithTracking) {
444 | return new BoundReflagClient(this, { enableTracking, ...context });
445 | }
446 |
447 | /**
448 | * Updates the associated user in Reflag.
449 | *
450 | * @param userId - The userId of the user to update.
451 | * @param options - The options for the user.
452 | * @param options.attributes - The additional attributes of the user (optional).
453 | * @param options.meta - The meta context associated with tracking (optional).
454 | *
455 | * @throws An error if the company is not set or the options are invalid.
456 | * @remarks
457 | * The company must be set using `withCompany` before calling this method.
458 | * If the user is set, the company will be associated with the user.
459 | **/
460 | public async updateUser(userId: IdType, options?: TrackOptions) {
461 | idOk(userId, "userId");
462 | ok(options === undefined || isObject(options), "options must be an object");
463 | ok(
464 | options?.attributes === undefined || isObject(options.attributes),
465 | "attributes must be an object",
466 | );
467 | checkMeta(options?.meta);
468 |
469 | if (this._config.offline) {
470 | return;
471 | }
472 |
473 | if (this.rateLimiter.isAllowed(hashObject({ ...options, userId }))) {
474 | await this.batchBuffer.add({
475 | type: "user",
476 | userId,
477 | attributes: options?.attributes,
478 | context: options?.meta,
479 | });
480 | }
481 | }
482 |
483 | /**
484 | * Updates the associated company in Reflag.
485 | *
486 | * @param companyId - The companyId of the company to update.
487 | * @param options - The options for the company.
488 | * @param options.attributes - The additional attributes of the company (optional).
489 | * @param options.meta - The meta context associated with tracking (optional).
490 | * @param options.userId - The userId of the user to associate with the company (optional).
491 | *
492 | * @throws An error if the company is not set or the options are invalid.
493 | * @remarks
494 | * The company must be set using `withCompany` before calling this method.
495 | * If the user is set, the company will be associated with the user.
496 | **/
497 | public async updateCompany(
498 | companyId: IdType,
499 | options?: TrackOptions & { userId?: IdType },
500 | ) {
501 | idOk(companyId, "companyId");
502 | ok(options === undefined || isObject(options), "options must be an object");
503 | ok(
504 | options?.attributes === undefined || isObject(options.attributes),
505 | "attributes must be an object",
506 | );
507 | checkMeta(options?.meta);
508 |
509 | if (typeof options?.userId !== "undefined") {
510 | idOk(options?.userId, "userId");
511 | }
512 |
513 | if (this._config.offline) {
514 | return;
515 | }
516 |
517 | if (this.rateLimiter.isAllowed(hashObject({ ...options, companyId }))) {
518 | await this.batchBuffer.add({
519 | type: "company",
520 | companyId,
521 | userId: options?.userId,
522 | attributes: options?.attributes,
523 | context: options?.meta,
524 | });
525 | }
526 | }
527 |
528 | /**
529 | * Tracks an event in Reflag.
530 |
531 | * @param options.companyId - Optional company ID for the event (optional).
532 | *
533 | * @throws An error if the user is not set or the event is invalid or the options are invalid.
534 | * @remarks
535 | * If the company is set, the event will be associated with the company.
536 | **/
537 | public async track(
538 | userId: IdType,
539 | event: string,
540 | options?: TrackOptions & { companyId?: IdType },
541 | ) {
542 | idOk(userId, "userId");
543 | ok(typeof event === "string" && event.length > 0, "event must be a string");
544 | ok(options === undefined || isObject(options), "options must be an object");
545 | ok(
546 | options?.attributes === undefined || isObject(options.attributes),
547 | "attributes must be an object",
548 | );
549 | ok(
550 | options?.meta === undefined || isObject(options.meta),
551 | "meta must be an object",
552 | );
553 | if (options?.companyId !== undefined) {
554 | idOk(options?.companyId, "companyId");
555 | }
556 |
557 | if (this._config.offline) {
558 | return;
559 | }
560 |
561 | await this.batchBuffer.add({
562 | type: "event",
563 | event,
564 | companyId: options?.companyId,
565 | userId,
566 | attributes: options?.attributes,
567 | context: options?.meta,
568 | });
569 | }
570 |
571 | /**
572 | * Initializes the client by caching the flags definitions.
573 | *
574 | * @remarks
575 | * Call this method before calling `getFlags` to ensure the flag definitions are cached.
576 | * The client will ignore subsequent calls to this method.
577 | **/
578 | public async initialize() {
579 | await this._initialize();
580 | return;
581 | }
582 |
583 | /**
584 | * Flushes and completes any in-flight fetches in the flag cache.
585 | *
586 | * @remarks
587 | * It is recommended to call this method when the application is shutting down to ensure all events are sent
588 | * before the process exits.
589 | *
590 | * This method is automatically called when the process exits if `batchOptions.flushOnExit` is `true` in the options (default).
591 | */
592 | public async flush() {
593 | if (this._config.offline) {
594 | return;
595 | }
596 |
597 | await this.batchBuffer.flush();
598 | await this.flagsCache.waitRefresh();
599 | }
600 |
601 | /**
602 | * Destroys the client and cleans up all resources including timers and background processes.
603 | *
604 | * @remarks
605 | * After calling this method, the client should not be used anymore.
606 | * This is particularly useful in development environments with hot reloading to prevent
607 | * multiple background processes from running simultaneously.
608 | */
609 | public destroy() {
610 | this.flagsCache.destroy();
611 | this.batchBuffer.destroy();
612 | }
613 |
614 | /**
615 | * Gets the flag definitions, including all config values.
616 | * To evaluate which flags are enabled for a given user/company, use `getFlags`.
617 | *
618 | * @returns The flags definitions.
619 | */
620 | public getFlagDefinitions(): FlagDefinition[] {
621 | const flags = this.flagsCache.get() || [];
622 | return flags.map((f) => ({
623 | key: f.key,
624 | description: f.description,
625 | flag: f.targeting,
626 | config: f.config,
627 | }));
628 | }
629 |
630 | /**
631 | * Gets the evaluated flags for the current context which includes the user, company, and custom context.
632 | *
633 | * @param options - The options for the context.
634 | * @param options.enableTracking - Whether to enable tracking for the context.
635 | * @param options.meta - The meta context associated with the context.
636 | * @param options.user - The user context.
637 | * @param options.company - The company context.
638 | * @param options.other - The other context.
639 | *
640 | * @returns The evaluated flags.
641 | *
642 | * @remarks
643 | * Call `initialize` before calling this method to ensure the flag definitions are cached, no flags will be returned otherwise.
644 | **/
645 | public getFlags({
646 | enableTracking = true,
647 | ...context
648 | }: ContextWithTracking): TypedFlags {
649 | const contextWithTracking = { enableTracking, ...context };
650 | const rawFlags = this._getFlags(contextWithTracking);
651 | return Object.fromEntries(
652 | Object.entries(rawFlags).map(([key, rawFlag]) => [
653 | key,
654 | this._wrapRawFlag(contextWithTracking, rawFlag),
655 | ]),
656 | );
657 | }
658 |
659 | /**
660 | * Gets the evaluated flag for the current context which includes the user, company, and custom context.
661 | * Using the `isEnabled` property sends a `check` event to Reflag.
662 | *
663 | * @param key - The key of the flag to get.
664 | * @returns The evaluated flag.
665 | *
666 | * @remarks
667 | * Call `initialize` before calling this method to ensure the flag definitions are cached, no flags will be returned otherwise.
668 | **/
669 | public getFlag<TKey extends TypedFlagKey>(
670 | { enableTracking = true, ...context }: ContextWithTracking,
671 | key: TKey,
672 | ): TypedFlags[TKey] {
673 | const contextWithTracking = { enableTracking, ...context };
674 | const rawFlag = this._getFlags(contextWithTracking, key);
675 | return this._wrapRawFlag(
676 | { enableChecks: true, ...contextWithTracking },
677 | rawFlag ?? { key },
678 | );
679 | }
680 |
681 | /**
682 | * Gets the evaluated flags for the current context without wrapping them in getters.
683 | * This method returns raw flag data suitable for bootstrapping client-side applications.
684 | *
685 | * @param options - The options for the context.
686 | * @param options.enableTracking - Whether to enable tracking for the context.
687 | * @param options.meta - The meta context associated with the context.
688 | * @param options.user - The user context.
689 | * @param options.company - The company context.
690 | * @param options.other - The other context.
691 | *
692 | * @returns The evaluated raw flags and the context.
693 | *
694 | * @remarks
695 | * Call `initialize` before calling this method to ensure the flag definitions are cached, no flags will be returned otherwise.
696 | * This method returns RawFlag objects without wrapping them in getters, making them suitable for serialization.
697 | **/
698 | public getFlagsForBootstrap({
699 | enableTracking = true,
700 | ...context
701 | }: ContextWithTracking): BootstrappedFlags {
702 | const contextWithTracking = { enableTracking, ...context };
703 | return {
704 | context: contextWithTracking,
705 | flags: this._getFlags(contextWithTracking),
706 | };
707 | }
708 |
709 | /**
710 | * Gets evaluated flags with the usage of remote context.
711 | * This method triggers a network request every time it's called.
712 | *
713 | * @param userId - The userId of the user to get the flags for.
714 | * @param companyId - The companyId of the company to get the flags for.
715 | * @param additionalContext - The additional context to get the flags for.
716 | *
717 | * @returns evaluated flags
718 | */
719 | public async getFlagsRemote(
720 | userId?: IdType,
721 | companyId?: IdType,
722 | additionalContext?: Context,
723 | ): Promise<TypedFlags> {
724 | return this._getFlagsRemote(
725 | undefined,
726 | userId,
727 | companyId,
728 | additionalContext,
729 | );
730 | }
731 |
732 | /**
733 | * Gets evaluated flag with the usage of remote context.
734 | * This method triggers a network request every time it's called.
735 | *
736 | * @param key - The key of the flag to get.
737 | * @param userId - The userId of the user to get the flag for.
738 | * @param companyId - The companyId of the company to get the flag for.
739 | * @param additionalContext - The additional context to get the flag for.
740 | *
741 | * @returns evaluated flag
742 | */
743 | public async getFlagRemote<TKey extends TypedFlagKey>(
744 | key: TKey,
745 | userId?: IdType,
746 | companyId?: IdType,
747 | additionalContext?: Context,
748 | ): Promise<TypedFlags[TKey]> {
749 | return this._getFlagsRemote(key, userId, companyId, additionalContext);
750 | }
751 |
752 | private buildUrl(path: string) {
753 | if (path.startsWith("/")) {
754 | path = path.slice(1);
755 | }
756 |
757 | const url = new URL(path, this._config.apiBaseUrl);
758 | return url.toString();
759 | }
760 |
761 | /**
762 | * Sends a POST request to the specified path.
763 | *
764 | * @param path - The path to send the request to.
765 | * @param body - The body of the request.
766 | *
767 | * @returns A boolean indicating if the request was successful.
768 | *
769 | * @throws An error if the path or body is invalid.
770 | **/
771 | private async post<TBody>(path: string, body: TBody) {
772 | ok(typeof path === "string" && path.length > 0, "path must be a string");
773 | ok(typeof body === "object", "body must be an object");
774 |
775 | const url = this.buildUrl(path);
776 | try {
777 | const response = await this.httpClient.post<TBody, { success: boolean }>(
778 | url,
779 | this._config.headers,
780 | body,
781 | );
782 |
783 | this.logger.debug(`post request to "${url}"`, response);
784 |
785 | if (!response.ok || !isObject(response.body) || !response.body.success) {
786 | this.logger.warn(
787 | `invalid response received from server for "${url}"`,
788 | JSON.stringify(response),
789 | );
790 | return false;
791 | }
792 | return true;
793 | } catch (error) {
794 | this.logger.error(`post request to "${url}" failed with error`, error);
795 | return false;
796 | }
797 | }
798 |
799 | /**
800 | * Sends a GET request to the specified path.
801 | *
802 | * @param path - The path to send the request to.
803 | * @param retries - Optional number of retries for the request.
804 | *
805 | * @returns The response from the server.
806 | * @throws An error if the path is invalid.
807 | **/
808 | private async get<TResponse>(path: string, retries: number = 3) {
809 | ok(typeof path === "string" && path.length > 0, "path must be a string");
810 |
811 | try {
812 | const url = this.buildUrl(path);
813 | return await withRetry(
814 | async () => {
815 | const response = await this.httpClient.get<
816 | TResponse & { success: boolean }
817 | >(url, this._config.headers, this._config.fetchTimeoutMs);
818 |
819 | this.logger.debug(`get request to "${url}"`, response);
820 |
821 | if (
822 | !response.ok ||
823 | !isObject(response.body) ||
824 | !response.body.success
825 | ) {
826 | throw new Error(
827 | `invalid response received from server for "${url}": ${JSON.stringify(response.body)}`,
828 | );
829 | }
830 | const { success: _, ...result } = response.body;
831 | return result as TResponse;
832 | },
833 | () => {
834 | this.logger.warn("failed to fetch flags, will retry");
835 | },
836 | retries,
837 | 1000,
838 | 10000,
839 | );
840 | } catch (error) {
841 | this.logger.error(
842 | `get request to "${path}" failed with error after ${retries} retries`,
843 | error,
844 | );
845 | return undefined;
846 | }
847 | }
848 |
849 | /**
850 | * Sends a batch of events to the Reflag API.
851 | *
852 | * @param events - The events to send.
853 | *
854 | * @throws An error if the send fails.
855 | **/
856 | private async sendBulkEvents(events: BulkEvent[]) {
857 | ok(
858 | Array.isArray(events) && events.length > 0,
859 | "events must be a non-empty array",
860 | );
861 |
862 | const sent = await this.post("bulk", events);
863 | if (!sent) {
864 | throw new Error("Failed to send bulk events");
865 | }
866 | }
867 |
868 | /**
869 | * Sends a flag event to the Reflag API.
870 | *
871 | * Flag events are used to track the evaluation of flag targeting rules.
872 | * "check" events are sent when a flag's `isEnabled` property is checked.
873 | * "evaluate" events are sent when a flag's targeting rules are matched against
874 | * the current context.
875 | *
876 | * @param event - The event to send.
877 | * @param event.action - The action to send.
878 | * @param event.key - The key of the flag to send.
879 | * @param event.targetingVersion - The targeting version of the flag to send.
880 | * @param event.evalResult - The evaluation result of the flag to send.
881 | * @param event.evalContext - The evaluation context of the flag to send.
882 | * @param event.evalRuleResults - The evaluation rule results of the flag to send.
883 | * @param event.evalMissingFields - The evaluation missing fields of the flag to send.
884 | *
885 | * @throws An error if the event is invalid.
886 | *
887 | * @remarks
888 | * This method is rate-limited to prevent too many events from being sent.
889 | **/
890 | private async sendFlagEvent(event: FlagEvent) {
891 | ok(typeof event === "object", "event must be an object");
892 | ok(
893 | typeof event.action === "string" &&
894 | (event.action === "check" || event.action === "check-config"),
895 | "event must have an action",
896 | );
897 | ok(
898 | typeof event.key === "string" && event.key.length > 0,
899 | "event must have a flag key",
900 | );
901 | ok(
902 | typeof event.targetingVersion === "number" ||
903 | event.targetingVersion === undefined,
904 | "event must have a targeting version",
905 | );
906 | ok(
907 | typeof event.evalResult === "boolean" || isObject(event.evalResult),
908 | "event must have an evaluation result",
909 | );
910 | ok(
911 | event.evalContext === undefined || typeof event.evalContext === "object",
912 | "event context must be an object",
913 | );
914 | ok(
915 | event.evalRuleResults === undefined ||
916 | Array.isArray(event.evalRuleResults),
917 | "event rule results must be an array",
918 | );
919 | ok(
920 | event.evalMissingFields === undefined ||
921 | Array.isArray(event.evalMissingFields),
922 | "event missing fields must be an array",
923 | );
924 |
925 | const contextKey = new URLSearchParams(
926 | flattenJSON(event.evalContext || {}),
927 | ).toString();
928 |
929 | if (this._config.offline) {
930 | return;
931 | }
932 |
933 | if (
934 | !this.rateLimiter.isAllowed(
935 | hashObject({
936 | action: event.action,
937 | key: event.key,
938 | targetingVersion: event.targetingVersion,
939 | evalResult: event.evalResult,
940 | contextKey,
941 | }),
942 | )
943 | ) {
944 | return;
945 | }
946 |
947 | await this.batchBuffer.add({
948 | type: "feature-flag-event",
949 | action: event.action,
950 | key: event.key,
951 | targetingVersion: event.targetingVersion,
952 | evalContext: event.evalContext,
953 | evalResult: event.evalResult,
954 | evalRuleResults: event.evalRuleResults,
955 | evalMissingFields: event.evalMissingFields,
956 | });
957 | }
958 |
959 | /**
960 | * Updates the context in Reflag (if needed).
961 | * This method should be used before requesting flags or binding a client.
962 | *
963 | * @param options - The options for the context.
964 | * @param options.enableTracking - Whether to enable tracking for the context.
965 | * @param options.meta - The meta context associated with the context.
966 | * @param options.user - The user context.
967 | * @param options.company - The company context.
968 | * @param options.other - The other context.
969 | */
970 | private async syncContext(options: ContextWithTracking) {
971 | if (!options.enableTracking) {
972 | this.logger.debug("tracking disabled, not updating user/company");
973 |
974 | return;
975 | }
976 |
977 | const promises: Promise<void>[] = [];
978 | if (typeof options.company?.id !== "undefined") {
979 | const { id: _, ...attributes } = options.company;
980 | promises.push(
981 | this.updateCompany(options.company.id, {
982 | attributes,
983 | meta: options.meta,
984 | }),
985 | );
986 | }
987 |
988 | if (typeof options.user?.id !== "undefined") {
989 | const { id: _, ...attributes } = options.user;
990 | promises.push(
991 | this.updateUser(options.user.id, {
992 | attributes,
993 | meta: options.meta,
994 | }),
995 | );
996 | }
997 |
998 | if (promises.length > 0) {
999 | await Promise.all(promises);
1000 | }
1001 | }
1002 |
1003 | /**
1004 | * Warns if a flag has targeting rules that require context fields that are missing.
1005 | *
1006 | * @param context - The context.
1007 | * @param flag - The flag to check.
1008 | */
1009 | private _warnMissingFlagContextFields(
1010 | context: Context,
1011 | flag: {
1012 | key: string;
1013 | missingContextFields?: string[];
1014 | config?: {
1015 | key: string;
1016 | missingContextFields?: string[];
1017 | };
1018 | },
1019 | ) {
1020 | const report: Record<string, string[]> = {};
1021 | const { config, ...flagData } = flag;
1022 |
1023 | if (
1024 | flagData.missingContextFields?.length &&
1025 | this.rateLimiter.isAllowed(
1026 | hashObject({
1027 | flagKey: flagData.key,
1028 | missingContextFields: flagData.missingContextFields,
1029 | context,
1030 | }),
1031 | )
1032 | ) {
1033 | report[flagData.key] = flagData.missingContextFields;
1034 | }
1035 |
1036 | if (
1037 | config?.missingContextFields?.length &&
1038 | this.rateLimiter.isAllowed(
1039 | hashObject({
1040 | flagKey: flagData.key,
1041 | configKey: config.key,
1042 | missingContextFields: config.missingContextFields,
1043 | context,
1044 | }),
1045 | )
1046 | ) {
1047 | report[`${flagData.key}.config`] = config.missingContextFields;
1048 | }
1049 |
1050 | if (Object.keys(report).length > 0) {
1051 | this.logger.warn(
1052 | `flag targeting rules might not be correctly evaluated due to missing context fields.`,
1053 | report,
1054 | );
1055 | }
1056 | }
1057 |
1058 | private _getFlags(options: ContextWithTracking): RawFlags;
1059 | private _getFlags<TKey extends TypedFlagKey>(
1060 | options: ContextWithTracking,
1061 | key: TKey,
1062 | ): RawFlag | undefined;
1063 | private _getFlags<TKey extends TypedFlagKey>(
1064 | options: ContextWithTracking,
1065 | key?: TKey,
1066 | ): RawFlags | RawFlag | undefined {
1067 | checkContextWithTracking(options);
1068 |
1069 | if (!this.initializationFinished) {
1070 | this.logger.error("getFlag(s): ReflagClient is not initialized yet.");
1071 | }
1072 |
1073 | void this.syncContext(options);
1074 | let flagDefinitions: CachedFlagDefinition[] = [];
1075 |
1076 | if (!this._config.offline) {
1077 | const flagDefs = this.flagsCache.get();
1078 | if (!flagDefs) {
1079 | this.logger.warn(
1080 | "no flag definitions available, using fallback flags.",
1081 | );
1082 | const fallbackFlags = this._config.fallbackFlags || {};
1083 | if (key) {
1084 | return fallbackFlags[key];
1085 | }
1086 | return fallbackFlags;
1087 | }
1088 | flagDefinitions = flagDefs;
1089 | }
1090 |
1091 | const { enableTracking: _, meta: __, ...context } = options;
1092 |
1093 | const evaluated = flagDefinitions
1094 | .filter(({ key: flagKey }) => (key ? key === flagKey : true))
1095 | .map((flag) => ({
1096 | flagKey: flag.key,
1097 | targetingVersion: flag.targeting.version,
1098 | configVersion: flag.config?.version,
1099 | enabledResult: flag.enabledEvaluator(context, flag.key),
1100 | configResult:
1101 | flag.configEvaluator?.(context, flag.key) ??
1102 | ({
1103 | flagKey: flag.key,
1104 | context,
1105 | value: undefined,
1106 | ruleEvaluationResults: [],
1107 | missingContextFields: [],
1108 | } satisfies EvaluationResult<any>),
1109 | }));
1110 |
1111 | let evaluatedFlags = evaluated.reduce((acc, res) => {
1112 | acc[res.flagKey as TypedFlagKey] = {
1113 | key: res.flagKey,
1114 | isEnabled: res.enabledResult.value ?? false,
1115 | ruleEvaluationResults: res.enabledResult.ruleEvaluationResults,
1116 | missingContextFields: res.enabledResult.missingContextFields,
1117 | targetingVersion: res.targetingVersion,
1118 | config: {
1119 | key: res.configResult?.value?.key,
1120 | payload: res.configResult?.value?.payload,
1121 | targetingVersion: res.configVersion,
1122 | ruleEvaluationResults: res.configResult?.ruleEvaluationResults,
1123 | missingContextFields: res.configResult?.missingContextFields,
1124 | },
1125 | };
1126 | return acc;
1127 | }, {} as RawFlags);
1128 |
1129 | const overrides = Object.entries(this._config.flagOverrides(context))
1130 | .filter(([flagKey]) => (key ? key === flagKey : true))
1131 | .map(([flagKey, override]) => [
1132 | flagKey,
1133 | isObject(override)
1134 | ? {
1135 | key: flagKey,
1136 | isEnabled: override.isEnabled,
1137 | config: override.config,
1138 | }
1139 | : {
1140 | key: flagKey,
1141 | isEnabled: !!override,
1142 | config: undefined,
1143 | },
1144 | ]);
1145 |
1146 | if (overrides.length > 0) {
1147 | // merge overrides into evaluated flags
1148 | evaluatedFlags = {
1149 | ...evaluatedFlags,
1150 | ...Object.fromEntries(overrides),
1151 | };
1152 | }
1153 |
1154 | if (key) {
1155 | return evaluatedFlags[key];
1156 | }
1157 |
1158 | return evaluatedFlags;
1159 | }
1160 |
1161 | private _wrapRawFlag<TKey extends TypedFlagKey>(
1162 | {
1163 | enableTracking,
1164 | enableChecks = false,
1165 | ...context
1166 | }: { enableTracking: boolean; enableChecks?: boolean } & Context,
1167 | { config, ...flag }: PartialBy<RawFlag, "isEnabled">,
1168 | ): TypedFlags[TKey] {
1169 | // eslint-disable-next-line @typescript-eslint/no-this-alias
1170 | const client = this;
1171 |
1172 | const simplifiedConfig = config
1173 | ? { key: config.key, payload: config.payload }
1174 | : { key: undefined, payload: undefined };
1175 |
1176 | return {
1177 | get isEnabled() {
1178 | if (enableTracking && enableChecks) {
1179 | client._warnMissingFlagContextFields(context, flag);
1180 |
1181 | void client
1182 | .sendFlagEvent({
1183 | action: "check",
1184 | key: flag.key,
1185 | targetingVersion: flag.targetingVersion,
1186 | evalResult: flag.isEnabled ?? false,
1187 | evalContext: context,
1188 | evalRuleResults: flag.ruleEvaluationResults,
1189 | evalMissingFields: flag.missingContextFields,
1190 | })
1191 | .catch((err) => {
1192 | client.logger?.error(
1193 | `failed to send check event for "${flag.key}": ${err}`,
1194 | err,
1195 | );
1196 | });
1197 | }
1198 | return flag.isEnabled ?? false;
1199 | },
1200 | get config() {
1201 | if (enableTracking && enableChecks) {
1202 | client._warnMissingFlagContextFields(context, flag);
1203 |
1204 | void client
1205 | .sendFlagEvent({
1206 | action: "check-config",
1207 | key: flag.key,
1208 | targetingVersion: config?.targetingVersion,
1209 | evalResult: simplifiedConfig,
1210 | evalContext: context,
1211 | evalRuleResults: config?.ruleEvaluationResults,
1212 | evalMissingFields: config?.missingContextFields,
1213 | })
1214 | .catch((err) => {
1215 | client.logger?.error(
1216 | `failed to send check event for "${flag.key}": ${err}`,
1217 | err,
1218 | );
1219 | });
1220 | }
1221 | return simplifiedConfig as TypedFlags[TKey]["config"];
1222 | },
1223 | key: flag.key,
1224 | track: async () => {
1225 | if (typeof context.user?.id === "undefined") {
1226 | this.logger.warn("no user set, cannot track event");
1227 | return;
1228 | }
1229 |
1230 | if (enableTracking) {
1231 | await this.track(context.user.id, flag.key, {
1232 | companyId: context.company?.id,
1233 | });
1234 | } else {
1235 | this.logger.debug("tracking disabled, not tracking event");
1236 | }
1237 | },
1238 | };
1239 | }
1240 |
1241 | private async _getFlagsRemote(
1242 | key: undefined,
1243 | userId?: IdType,
1244 | companyId?: IdType,
1245 | additionalContext?: Context,
1246 | ): Promise<TypedFlags>;
1247 | private async _getFlagsRemote<TKey extends TypedFlagKey>(
1248 | key: TKey,
1249 | userId?: IdType,
1250 | companyId?: IdType,
1251 | additionalContext?: Context,
1252 | ): Promise<TypedFlags[TKey]>;
1253 | private async _getFlagsRemote<TKey extends TypedFlagKey>(
1254 | key?: string,
1255 | userId?: IdType,
1256 | companyId?: IdType,
1257 | additionalContext?: Context,
1258 | ): Promise<TypedFlags | TypedFlags[TKey]> {
1259 | const context = additionalContext || {};
1260 | if (userId) {
1261 | context.user = { id: userId };
1262 | }
1263 | if (companyId) {
1264 | context.company = { id: companyId };
1265 | }
1266 |
1267 | const contextWithTracking = {
1268 | ...context,
1269 | enableTracking: true,
1270 | };
1271 |
1272 | checkContextWithTracking(contextWithTracking);
1273 |
1274 | const params = new URLSearchParams(
1275 | Object.keys(context).length ? flattenJSON({ context }) : undefined,
1276 | );
1277 |
1278 | if (key) {
1279 | params.append("key", key);
1280 | }
1281 |
1282 | const res = await this.get<EvaluatedFlagsAPIResponse>(
1283 | `features/evaluated?${params}`,
1284 | );
1285 |
1286 | if (key) {
1287 | const flag = res?.features[key];
1288 | if (!flag) {
1289 | this.logger.error(`flag ${key} not found`);
1290 | }
1291 | return this._wrapRawFlag(contextWithTracking, { key, ...flag });
1292 | } else {
1293 | return res?.features
1294 | ? Object.fromEntries(
1295 | Object.entries(res?.features).map(([flagKey, flag]) => [
1296 | flagKey,
1297 | this._wrapRawFlag(contextWithTracking, flag),
1298 | ]),
1299 | )
1300 | : {};
1301 | }
1302 | }
1303 | }
1304 |
1305 | /**
1306 | * A client bound with a specific user, company, and other context.
1307 | */
1308 | export class BoundReflagClient {
1309 | private readonly _client: ReflagClient;
1310 | private readonly _options: ContextWithTracking;
1311 |
1312 | /**
1313 | * (Internal) Creates a new BoundReflagClient. Use `bindClient` to create a new client bound with a specific context.
1314 | *
1315 | * @param client - The `ReflagClient` to use.
1316 | * @param options - The options for the client.
1317 | * @param options.enableTracking - Whether to enable tracking for the client.
1318 | *
1319 | * @internal
1320 | */
1321 | constructor(
1322 | client: ReflagClient,
1323 | { enableTracking = true, ...context }: ContextWithTracking,
1324 | ) {
1325 | this._client = client;
1326 |
1327 | this._options = { enableTracking, ...context };
1328 |
1329 | checkContextWithTracking(this._options);
1330 | void this._client["syncContext"](this._options);
1331 | }
1332 |
1333 | /**
1334 | * Gets the "other" context associated with the client.
1335 | *
1336 | * @returns The "other" context or `undefined` if it is not set.
1337 | **/
1338 | public get otherContext() {
1339 | return this._options.other;
1340 | }
1341 |
1342 | /**
1343 | * Gets the user associated with the client.
1344 | *
1345 | * @returns The user or `undefined` if it is not set.
1346 | **/
1347 | public get user() {
1348 | return this._options.user;
1349 | }
1350 |
1351 | /**
1352 | * Gets the company associated with the client.
1353 | *
1354 | * @returns The company or `undefined` if it is not set.
1355 | **/
1356 | public get company() {
1357 | return this._options.company;
1358 | }
1359 |
1360 | /**
1361 | * Get flags for the user/company/other context bound to this client.
1362 | * Meant for use in serialization of flags for transferring to the client-side/browser.
1363 | *
1364 | * @returns Flags for the given user/company and whether each one is enabled or not
1365 | */
1366 | public getFlags(): TypedFlags {
1367 | return this._client.getFlags(this._options);
1368 | }
1369 |
1370 | /**
1371 | * Get raw flags for the user/company/other context bound to this client without wrapping them in getters.
1372 | * This method returns raw flag data suitable for bootstrapping client-side applications.
1373 | *
1374 | * @returns Raw flags for the given user/company and whether each one is enabled or not
1375 | */
1376 | public getFlagsForBootstrap(): BootstrappedFlags {
1377 | return this._client.getFlagsForBootstrap({ ...this._options });
1378 | }
1379 |
1380 | /**
1381 | * Get a specific flag for the user/company/other context bound to this client.
1382 | * Using the `isEnabled` property sends a `check` event to Reflag.
1383 | *
1384 | * @param key - The key of the flag to get.
1385 | *
1386 | * @returns Flags for the given user/company and whether each one is enabled or not
1387 | */
1388 | public getFlag<TKey extends TypedFlagKey>(key: TKey): TypedFlags[TKey] {
1389 | return this._client.getFlag(this._options, key);
1390 | }
1391 |
1392 | /**
1393 | * Get remotely evaluated flag for the user/company/other context bound to this client.
1394 | *
1395 | * @returns Flags for the given user/company and whether each one is enabled or not
1396 | */
1397 | public async getFlagsRemote() {
1398 | const { enableTracking: _, meta: __, ...context } = this._options;
1399 | return await this._client.getFlagsRemote(undefined, undefined, context);
1400 | }
1401 |
1402 | /**
1403 | * Get remotely evaluated flag for the user/company/other context bound to this client.
1404 | *
1405 | * @param key - The key of the flag to get.
1406 | *
1407 | * @returns Flag for the given user/company and key and whether it's enabled or not
1408 | */
1409 | public async getFlagRemote(key: string) {
1410 | const { enableTracking: _, meta: __, ...context } = this._options;
1411 | return await this._client.getFlagRemote(key, undefined, undefined, context);
1412 | }
1413 |
1414 | /**
1415 | * Track an event in Reflag.
1416 | *
1417 | * @param event - The event to track.
1418 | * @param options - The options for the event.
1419 | * @param options.attributes - The attributes of the event (optional).
1420 | * @param options.meta - The meta context associated with tracking (optional).
1421 | * @param options.companyId - Optional company ID for the event (optional).
1422 | *
1423 | * @throws An error if the event is invalid or the options are invalid.
1424 | */
1425 | public async track(
1426 | event: string,
1427 | options?: TrackOptions & { companyId?: string },
1428 | ) {
1429 | ok(options === undefined || isObject(options), "options must be an object");
1430 | checkMeta(options?.meta);
1431 |
1432 | const userId = this._options.user?.id;
1433 |
1434 | if (!userId) {
1435 | this._client.logger?.warn("no user set, cannot track event");
1436 | return;
1437 | }
1438 |
1439 | if (!this._options.enableTracking) {
1440 | this._client.logger?.debug(
1441 | "tracking disabled for this bound client, not tracking event",
1442 | );
1443 | return;
1444 | }
1445 |
1446 | await this._client.track(
1447 | userId,
1448 | event,
1449 | options?.companyId
1450 | ? options
1451 | : { ...options, companyId: this._options.company?.id },
1452 | );
1453 | }
1454 |
1455 | /**
1456 | * Create a new client bound with the additional context.
1457 | * Note: This performs a shallow merge for user/company/other individually.
1458 | *
1459 | * @param context - The context to bind the client to.
1460 | * @param context.user - The user to bind the client to.
1461 | * @param context.company - The company to bind the client to.
1462 | * @param context.other - The other context to bind the client to.
1463 | * @param context.enableTracking - Whether to enable tracking for the client.
1464 | * @param context.meta - The meta context to bind the client to.
1465 | *
1466 | * @returns new client bound with the additional context
1467 | */
1468 | public bindClient({
1469 | user,
1470 | company,
1471 | other,
1472 | enableTracking,
1473 | meta,
1474 | }: ContextWithTracking) {
1475 | // merge new context into existing
1476 | const boundConfig = {
1477 | ...this._options,
1478 | user: user ? { ...this._options.user, ...user } : undefined,
1479 | company: company ? { ...this._options.company, ...company } : undefined,
1480 | other: { ...this._options.other, ...other },
1481 | enableTracking: enableTracking ?? this._options.enableTracking,
1482 | meta: meta ?? this._options.meta,
1483 | };
1484 |
1485 | return new BoundReflagClient(this._client, boundConfig);
1486 | }
1487 |
1488 | /**
1489 | * Flushes the batch buffer.
1490 | */
1491 | public async flush() {
1492 | await this._client.flush();
1493 | }
1494 | }
1495 |
1496 | function checkMeta(
1497 | meta?: TrackingMeta,
1498 | ): asserts meta is TrackingMeta | undefined {
1499 | ok(
1500 | typeof meta === "undefined" || isObject(meta),
1501 | "meta must be an object if given",
1502 | );
1503 | ok(
1504 | meta?.active === undefined || typeof meta?.active === "boolean",
1505 | "meta.active must be a boolean if given",
1506 | );
1507 | }
1508 |
1509 | function checkContext(context: Context): asserts context is Context {
1510 | ok(isObject(context), "context must be an object");
1511 | ok(
1512 | typeof context.user === "undefined" || isObject(context.user),
1513 | "user must be an object if given",
1514 | );
1515 | if (typeof context.user?.id !== "undefined") {
1516 | idOk(context.user.id, "user.id");
1517 | }
1518 |
1519 | ok(
1520 | typeof context.company === "undefined" || isObject(context.company),
1521 | "company must be an object if given",
1522 | );
1523 | if (typeof context.company?.id !== "undefined") {
1524 | idOk(context.company.id, "company.id");
1525 | }
1526 |
1527 | ok(
1528 | context.other === undefined || isObject(context.other),
1529 | "other must be an object if given",
1530 | );
1531 | }
1532 |
1533 | function checkContextWithTracking(
1534 | context: ContextWithTracking,
1535 | ): asserts context is ContextWithTracking & { enableTracking: boolean } {
1536 | checkContext(context);
1537 |
1538 | ok(
1539 | typeof context.enableTracking === "boolean",
1540 | "enableTracking must be a boolean",
1541 | );
1542 |
1543 | checkMeta(context.meta);
1544 | }
1545 |
```
--------------------------------------------------------------------------------
/packages/node-sdk/test/client.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import flushPromises from "flush-promises";
2 | import {
3 | afterEach,
4 | beforeAll,
5 | beforeEach,
6 | describe,
7 | expect,
8 | it,
9 | test,
10 | vi,
11 | } from "vitest";
12 |
13 | import { BoundReflagClient, ReflagClient } from "../src";
14 | import {
15 | API_BASE_URL,
16 | API_TIMEOUT_MS,
17 | BATCH_INTERVAL_MS,
18 | BATCH_MAX_SIZE,
19 | FLAG_EVENT_RATE_LIMITER_WINDOW_SIZE_MS,
20 | FLAGS_REFETCH_MS,
21 | SDK_VERSION,
22 | SDK_VERSION_HEADER_NAME,
23 | } from "../src/config";
24 | import fetchClient from "../src/fetch-http-client";
25 | import { subscribe as triggerOnExit } from "../src/flusher";
26 | import { newRateLimiter } from "../src/rate-limiter";
27 | import { ClientOptions, Context, FlagsAPIResponse } from "../src/types";
28 |
29 | const BULK_ENDPOINT = "https://api.example.com/bulk";
30 |
31 | vi.mock("../src/rate-limiter", async (importOriginal) => {
32 | const original = (await importOriginal()) as any;
33 |
34 | return {
35 | ...original,
36 | newRateLimiter: vi.fn(original.newRateLimiter),
37 | };
38 | });
39 |
40 | vi.mock("../src/flusher", () => ({
41 | subscribe: vi.fn(),
42 | }));
43 |
44 | // Mock NextJS headers module for dynamic import
45 | vi.mock("next/headers", () => ({
46 | cookies: vi.fn(),
47 | }));
48 |
49 | const user = {
50 | id: "user123",
51 | age: 1,
52 | name: "John",
53 | };
54 |
55 | const company = {
56 | id: "company123",
57 | employees: 100,
58 | name: "Acme Inc.",
59 | };
60 |
61 | const event = {
62 | event: "flag-event",
63 | attrs: { key: "value" },
64 | };
65 |
66 | const otherContext = { custom: "context", key: "value" };
67 | const logger = {
68 | debug: vi.fn(),
69 | info: vi.fn(),
70 | warn: vi.fn(),
71 | error: vi.fn(),
72 | };
73 | const httpClient = { post: vi.fn(), get: vi.fn() };
74 |
75 | const fallbackFlags = ["key"];
76 |
77 | const validOptions: ClientOptions = {
78 | secretKey: "validSecretKeyWithMoreThan22Chars",
79 | apiBaseUrl: "https://api.example.com/",
80 | logger,
81 | httpClient,
82 | fallbackFlags,
83 | flagsFetchRetries: 2,
84 | batchOptions: {
85 | maxSize: 99,
86 | intervalMs: 10001,
87 | flushOnExit: false,
88 | },
89 | offline: false,
90 | };
91 |
92 | const expectedHeaders = {
93 | [SDK_VERSION_HEADER_NAME]: SDK_VERSION,
94 | "Content-Type": "application/json",
95 | Authorization: `Bearer ${validOptions.secretKey}`,
96 | };
97 |
98 | const flagDefinitions: FlagsAPIResponse = {
99 | features: [
100 | {
101 | key: "flag1",
102 | description: "Flag 1",
103 | targeting: {
104 | version: 1,
105 | rules: [
106 | {
107 | filter: {
108 | type: "context" as const,
109 | field: "company.id",
110 | operator: "IS",
111 | values: ["company123"],
112 | },
113 | },
114 | ],
115 | },
116 | config: {
117 | version: 1,
118 | variants: [
119 | {
120 | filter: {
121 | type: "context",
122 | field: "company.id",
123 | operator: "IS",
124 | values: ["company123"],
125 | },
126 | key: "config-1",
127 | payload: { something: "else" },
128 | },
129 | ],
130 | },
131 | },
132 | {
133 | key: "flag2",
134 | description: "Flag 2",
135 | targeting: {
136 | version: 2,
137 | rules: [
138 | {
139 | filter: {
140 | type: "group" as const,
141 | operator: "and",
142 | filters: [
143 | {
144 | type: "context" as const,
145 | field: "company.id",
146 | operator: "IS",
147 | values: ["company123"],
148 | },
149 | {
150 | partialRolloutThreshold: 0.5,
151 | partialRolloutAttribute: "attributeKey",
152 | type: "rolloutPercentage" as const,
153 | key: "flag2",
154 | },
155 | ],
156 | },
157 | },
158 | ],
159 | },
160 | },
161 | ],
162 | };
163 |
164 | describe("ReflagClient", () => {
165 | afterEach(() => {
166 | vi.clearAllMocks();
167 | });
168 |
169 | describe("constructor", () => {
170 | it("should initialize with no options", async () => {
171 | const secretKeyEnv = process.env.REFLAG_SECRET_KEY;
172 | process.env.REFLAG_SECRET_KEY = "validSecretKeyWithMoreThan22Chars";
173 | try {
174 | const reflagInstance = new ReflagClient();
175 | expect(reflagInstance).toBeInstanceOf(ReflagClient);
176 | } finally {
177 | process.env.REFLAG_SECRET_KEY = secretKeyEnv;
178 | }
179 | });
180 |
181 | it("should accept fallback flags as an array", async () => {
182 | const reflagInstance = new ReflagClient({
183 | secretKey: "validSecretKeyWithMoreThan22Chars",
184 | fallbackFlags: ["flag1", "flag2"],
185 | });
186 |
187 | expect(reflagInstance["_config"].fallbackFlags).toEqual({
188 | flag1: {
189 | isEnabled: true,
190 | key: "flag1",
191 | },
192 | flag2: {
193 | isEnabled: true,
194 | key: "flag2",
195 | },
196 | });
197 | });
198 |
199 | it("should accept fallback flags as an object", async () => {
200 | const reflagInstance = new ReflagClient({
201 | secretKey: "validSecretKeyWithMoreThan22Chars",
202 | fallbackFlags: {
203 | flag1: true,
204 | flag2: {
205 | isEnabled: true,
206 | config: {
207 | key: "config1",
208 | payload: { value: true },
209 | },
210 | },
211 | },
212 | });
213 |
214 | expect(reflagInstance["_config"].fallbackFlags).toStrictEqual({
215 | flag1: {
216 | key: "flag1",
217 | config: undefined,
218 | isEnabled: true,
219 | },
220 | flag2: {
221 | key: "flag2",
222 | isEnabled: true,
223 | config: {
224 | key: "config1",
225 | payload: { value: true },
226 | },
227 | },
228 | });
229 | });
230 |
231 | it("should create a client instance with valid options", () => {
232 | const client = new ReflagClient(validOptions);
233 |
234 | expect(client).toBeInstanceOf(ReflagClient);
235 | expect(client["_config"].apiBaseUrl).toBe("https://api.example.com/");
236 | expect(client["_config"].refetchInterval).toBe(FLAGS_REFETCH_MS);
237 | expect(client["_config"].staleWarningInterval).toBe(FLAGS_REFETCH_MS * 5);
238 | expect(client.logger).toBeDefined();
239 | expect(client.httpClient).toBe(validOptions.httpClient);
240 | expect(client["_config"].headers).toEqual(expectedHeaders);
241 | expect(client["batchBuffer"]).toMatchObject({
242 | maxSize: 99,
243 | intervalMs: 10001,
244 | });
245 |
246 | expect(client["_config"].fallbackFlags).toEqual({
247 | key: {
248 | key: "key",
249 | isEnabled: true,
250 | },
251 | });
252 | expect(client["_config"].flagsFetchRetries).toBe(2);
253 | });
254 |
255 | it("should route messages to the supplied logger", () => {
256 | const client = new ReflagClient(validOptions);
257 |
258 | const actualLogger = client.logger!;
259 | actualLogger.debug("debug message");
260 | actualLogger.info("info message");
261 | actualLogger.warn("warn message");
262 | actualLogger.error("error message");
263 |
264 | expect(logger.debug).toHaveBeenCalledWith(
265 | expect.stringMatching("debug message"),
266 | );
267 | expect(logger.info).toHaveBeenCalledWith(
268 | expect.stringMatching("info message"),
269 | );
270 | expect(logger.warn).toHaveBeenCalledWith(
271 | expect.stringMatching("warn message"),
272 | );
273 | expect(logger.error).toHaveBeenCalledWith(
274 | expect.stringMatching("error message"),
275 | );
276 | });
277 |
278 | it("should create a client instance with default values for optional fields", () => {
279 | const client = new ReflagClient({
280 | secretKey: "validSecretKeyWithMoreThan22Chars",
281 | });
282 |
283 | expect(client["_config"].apiBaseUrl).toBe(API_BASE_URL);
284 | expect(client["_config"].refetchInterval).toBe(FLAGS_REFETCH_MS);
285 | expect(client["_config"].staleWarningInterval).toBe(FLAGS_REFETCH_MS * 5);
286 | expect(client.httpClient).toBe(fetchClient);
287 | expect(client["_config"].headers).toEqual(expectedHeaders);
288 | expect(client["_config"].fallbackFlags).toBeUndefined();
289 | expect(client["batchBuffer"]).toMatchObject({
290 | maxSize: BATCH_MAX_SIZE,
291 | intervalMs: BATCH_INTERVAL_MS,
292 | });
293 | });
294 |
295 | it("should throw an error if options are invalid", () => {
296 | let invalidOptions: any = null;
297 | expect(() => new ReflagClient(invalidOptions)).toThrow(
298 | "options must be an object",
299 | );
300 |
301 | invalidOptions = { ...validOptions, secretKey: "shortKey" };
302 | expect(() => new ReflagClient(invalidOptions)).toThrow(
303 | "invalid secretKey specified",
304 | );
305 |
306 | invalidOptions = { ...validOptions, host: 123 };
307 | expect(() => new ReflagClient(invalidOptions)).toThrow(
308 | "host must be a string",
309 | );
310 |
311 | invalidOptions = {
312 | ...validOptions,
313 | logger: "invalidLogger" as any,
314 | };
315 | expect(() => new ReflagClient(invalidOptions)).toThrow(
316 | "logger must be an object",
317 | );
318 |
319 | invalidOptions = {
320 | ...validOptions,
321 | httpClient: "invalidHttpClient" as any,
322 | };
323 | expect(() => new ReflagClient(invalidOptions)).toThrow(
324 | "httpClient must be an object",
325 | );
326 |
327 | invalidOptions = {
328 | ...validOptions,
329 | batchOptions: "invalid" as any,
330 | };
331 | expect(() => new ReflagClient(invalidOptions)).toThrow(
332 | "batchOptions must be an object",
333 | );
334 |
335 | invalidOptions = {
336 | ...validOptions,
337 | fallbackFlags: "invalid" as any,
338 | };
339 | expect(() => new ReflagClient(invalidOptions)).toThrow(
340 | "fallbackFlags must be an array or object",
341 | );
342 | });
343 |
344 | it("should create a new flag events rate-limiter", () => {
345 | const client = new ReflagClient(validOptions);
346 |
347 | expect(client["rateLimiter"]).toBeDefined();
348 | expect(newRateLimiter).toHaveBeenCalledWith(
349 | FLAG_EVENT_RATE_LIMITER_WINDOW_SIZE_MS,
350 | );
351 | });
352 |
353 | it("should not register an exit flush handler if `batchOptions.flushOnExit` is false", () => {
354 | new ReflagClient({
355 | ...validOptions,
356 | batchOptions: { ...validOptions.batchOptions, flushOnExit: false },
357 | });
358 |
359 | expect(triggerOnExit).not.toHaveBeenCalled();
360 | });
361 |
362 | it("should not register an exit flush handler if `offline` is true", () => {
363 | new ReflagClient({
364 | ...validOptions,
365 | offline: true,
366 | });
367 |
368 | expect(triggerOnExit).not.toHaveBeenCalled();
369 | });
370 |
371 | it.each([undefined, true])(
372 | "should register an exit flush handler if `batchOptions.flushOnExit` is `%s`",
373 | (flushOnExit) => {
374 | new ReflagClient({
375 | ...validOptions,
376 | batchOptions: { ...validOptions.batchOptions, flushOnExit },
377 | });
378 |
379 | expect(triggerOnExit).toHaveBeenCalledWith(expect.any(Function));
380 | },
381 | );
382 |
383 | it.each([
384 | ["https://api.example.com", "https://api.example.com/bulk"],
385 | ["https://api.example.com/", "https://api.example.com/bulk"],
386 | ["https://api.example.com/path", "https://api.example.com/path/bulk"],
387 | ["https://api.example.com/path/", "https://api.example.com/path/bulk"],
388 | ])(
389 | "should build the URLs correctly %s -> %s",
390 | async (apiBaseUrl, expectedUrl) => {
391 | const client = new ReflagClient({
392 | ...validOptions,
393 | apiBaseUrl,
394 | });
395 |
396 | await client.updateUser("user_id");
397 | await client.flush();
398 |
399 | expect(httpClient.post).toHaveBeenCalledWith(
400 | expectedUrl,
401 | expect.any(Object),
402 | expect.any(Object),
403 | );
404 | },
405 | );
406 | });
407 |
408 | describe("bindClient", () => {
409 | const client = new ReflagClient(validOptions);
410 | const context = {
411 | user,
412 | company,
413 | other: otherContext,
414 | };
415 |
416 | beforeEach(() => {
417 | vi.mocked(httpClient.post).mockResolvedValue({ body: { success: true } });
418 | client["rateLimiter"].clearStale(true);
419 | });
420 |
421 | it("should return a new client instance with the `user`, `company` and `other` set", async () => {
422 | const newClient = client.bindClient(context);
423 | await client.flush();
424 |
425 | expect(newClient.user).toEqual(user);
426 | expect(newClient.company).toEqual(company);
427 | expect(newClient.otherContext).toEqual(otherContext);
428 |
429 | expect(newClient).toBeInstanceOf(BoundReflagClient);
430 | expect(newClient).not.toBe(client); // Ensure a new instance is returned
431 | expect(newClient["_options"]).toEqual({
432 | enableTracking: true,
433 | ...context,
434 | });
435 | });
436 |
437 | it("should update user in Reflag when called", async () => {
438 | client.bindClient({ user: context.user });
439 | await client.flush();
440 |
441 | const { id: _, ...attributes } = context.user;
442 |
443 | expect(httpClient.post).toHaveBeenCalledWith(
444 | BULK_ENDPOINT,
445 | expectedHeaders,
446 | [
447 | {
448 | type: "user",
449 | userId: user.id,
450 | attributes: attributes,
451 | context: undefined,
452 | },
453 | ],
454 | );
455 |
456 | expect(httpClient.post).toHaveBeenCalledOnce();
457 | });
458 |
459 | it("should update company in Reflag when called", async () => {
460 | client.bindClient({ company: context.company, meta: { active: true } });
461 | await client.flush();
462 |
463 | const { id: _, ...attributes } = context.company;
464 |
465 | expect(httpClient.post).toHaveBeenCalledWith(
466 | BULK_ENDPOINT,
467 | expectedHeaders,
468 | [
469 | {
470 | type: "company",
471 | companyId: company.id,
472 | attributes: attributes,
473 | context: {
474 | active: true,
475 | },
476 | },
477 | ],
478 | );
479 |
480 | expect(httpClient.post).toHaveBeenCalledOnce();
481 | });
482 |
483 | it("should not update `company` or `user` in Reflag when `enableTracking` is `false`", async () => {
484 | client.bindClient({
485 | user: context.user,
486 | company: context.company,
487 | enableTracking: false,
488 | });
489 |
490 | await client.flush();
491 |
492 | expect(httpClient.post).not.toHaveBeenCalled();
493 | });
494 |
495 | it("should throw an error if `user` is invalid", () => {
496 | expect(() =>
497 | client.bindClient({ user: "bad_attributes" as any }),
498 | ).toThrow("validation failed: user must be an object if given");
499 | expect(() => client.bindClient({ user: { id: {} as any } })).toThrow(
500 | "validation failed: user.id must be a string or number if given",
501 | );
502 | });
503 |
504 | it("should throw an error if `company` is invalid", () => {
505 | expect(() =>
506 | client.bindClient({ company: "bad_attributes" as any }),
507 | ).toThrow("validation failed: company must be an object if given");
508 | expect(() => client.bindClient({ company: { id: {} as any } })).toThrow(
509 | "validation failed: company.id must be a string or number if given",
510 | );
511 | });
512 |
513 | it("should throw an error if `other` is invalid", () => {
514 | expect(() =>
515 | client.bindClient({ other: "bad_attributes" as any }),
516 | ).toThrow("validation failed: other must be an object");
517 | });
518 |
519 | it("should throw an error if `enableTracking` is invalid", () => {
520 | expect(() =>
521 | client.bindClient({ enableTracking: "bad_attributes" as any }),
522 | ).toThrow("validation failed: enableTracking must be a boolean");
523 | });
524 |
525 | it("should allow context without id", () => {
526 | const c = client.bindClient({
527 | user: { id: undefined, name: "userName" },
528 | company: { id: undefined, name: "companyName" },
529 | });
530 | expect(c.user?.id).toBeUndefined();
531 | expect(c.company?.id).toBeUndefined();
532 | });
533 | });
534 |
535 | describe("updateUser", () => {
536 | const client = new ReflagClient(validOptions);
537 |
538 | beforeEach(() => {
539 | client["rateLimiter"].clearStale(true);
540 | });
541 |
542 | // try with both string and number IDs
543 | test.each([
544 | { id: "user123", age: 1, name: "John" },
545 | { id: 42, age: 1, name: "John" },
546 | ])("should successfully update the user", async (testUser) => {
547 | const response = { status: 200, body: { success: true } };
548 | httpClient.post.mockResolvedValue(response);
549 |
550 | await client.updateUser(testUser.id, {
551 | attributes: { age: 2, brave: false },
552 | meta: {
553 | active: true,
554 | },
555 | });
556 |
557 | await client.flush();
558 |
559 | expect(httpClient.post).toHaveBeenCalledWith(
560 | BULK_ENDPOINT,
561 | expectedHeaders,
562 | [
563 | {
564 | type: "user",
565 | userId: testUser.id,
566 | attributes: { age: 2, brave: false },
567 | context: { active: true },
568 | },
569 | ],
570 | );
571 |
572 | expect(logger.debug).toHaveBeenCalledWith(
573 | expect.stringMatching("post request to "),
574 | response,
575 | );
576 | });
577 |
578 | it("should log an error if the post request throws", async () => {
579 | const error = new Error("Network error");
580 | httpClient.post.mockRejectedValue(error);
581 |
582 | await client.updateUser(user.id);
583 | await client.flush();
584 |
585 | expect(logger.error).toHaveBeenCalledWith(
586 | expect.stringMatching("post request to .* failed with error"),
587 | error,
588 | );
589 | });
590 |
591 | it("should log if API call returns false", async () => {
592 | const response = { status: 200, body: { success: false } };
593 |
594 | httpClient.post.mockResolvedValue(response);
595 |
596 | await client.updateUser(user.id);
597 | await client.flush();
598 |
599 | expect(logger.warn).toHaveBeenCalledWith(
600 | expect.stringMatching("invalid response received from server for"),
601 | JSON.stringify(response),
602 | );
603 | });
604 |
605 | it("should throw an error if opts are not valid or the user is not set", async () => {
606 | await expect(
607 | client.updateUser(user.id, "bad_opts" as any),
608 | ).rejects.toThrow("validation failed: options must be an object");
609 |
610 | await expect(
611 | client.updateUser(user.id, { attributes: "bad_attributes" as any }),
612 | ).rejects.toThrow("attributes must be an object");
613 |
614 | await expect(
615 | client.updateUser(user.id, { meta: "bad_meta" as any }),
616 | ).rejects.toThrow("meta must be an object");
617 | });
618 | });
619 |
620 | describe("updateCompany", () => {
621 | const client = new ReflagClient(validOptions);
622 |
623 | beforeEach(() => {
624 | client["rateLimiter"].clearStale(true);
625 | });
626 |
627 | test.each([
628 | {
629 | id: "company123",
630 | employees: 100,
631 | name: "Acme Inc.",
632 | userId: "user123",
633 | },
634 | { id: 42, employees: 100, name: "Acme Inc.", userId: 42 },
635 | ])(`should successfully update the company`, async (testCompany) => {
636 | const response = { status: 200, body: { success: true } };
637 | httpClient.post.mockResolvedValue(response);
638 |
639 | await client.updateCompany(testCompany.id, {
640 | attributes: { employees: 200, bankrupt: false },
641 | meta: { active: true },
642 | userId: testCompany.userId,
643 | });
644 |
645 | await client.flush();
646 |
647 | expect(httpClient.post).toHaveBeenCalledWith(
648 | BULK_ENDPOINT,
649 | expectedHeaders,
650 | [
651 | {
652 | type: "company",
653 | companyId: testCompany.id,
654 | attributes: { employees: 200, bankrupt: false },
655 | context: { active: true },
656 | userId: testCompany.userId,
657 | },
658 | ],
659 | );
660 |
661 | expect(logger.debug).toHaveBeenCalledWith(
662 | expect.stringMatching("post request to .*"),
663 | response,
664 | );
665 | });
666 |
667 | it("should log an error if the post request throws", async () => {
668 | const error = new Error("Network error");
669 | httpClient.post.mockRejectedValue(error);
670 |
671 | await client.updateCompany(company.id, {});
672 | await client.flush();
673 |
674 | expect(logger.error).toHaveBeenCalledWith(
675 | expect.stringMatching("post request to .* failed with error"),
676 | error,
677 | );
678 | });
679 |
680 | it("should log an error if API responds with success: false", async () => {
681 | const response = {
682 | status: 200,
683 | body: { success: false },
684 | };
685 |
686 | httpClient.post.mockResolvedValue(response);
687 |
688 | await client.updateCompany(company.id, {});
689 | await client.flush();
690 |
691 | expect(logger.warn).toHaveBeenCalledWith(
692 | expect.stringMatching("invalid response received from server for"),
693 | JSON.stringify(response),
694 | );
695 | });
696 |
697 | it("should throw an error if company is not valid", async () => {
698 | await expect(
699 | client.updateCompany(company.id, "bad_opts" as any),
700 | ).rejects.toThrow("validation failed: options must be an object");
701 |
702 | await expect(
703 | client.updateCompany(company.id, {
704 | attributes: "bad_attributes" as any,
705 | }),
706 | ).rejects.toThrow("attributes must be an object");
707 |
708 | await expect(
709 | client.updateCompany(company.id, { meta: "bad_meta" as any }),
710 | ).rejects.toThrow("meta must be an object");
711 | });
712 | });
713 |
714 | describe("track", () => {
715 | const client = new ReflagClient(validOptions);
716 |
717 | beforeEach(() => {
718 | client["rateLimiter"].clearStale(true);
719 | });
720 |
721 | test.each([
722 | { id: "user123", age: 1, name: "John" },
723 | { id: 42, age: 1, name: "John" },
724 | ])("should successfully track the flag usage", async (testUser) => {
725 | const response = {
726 | status: 200,
727 | body: { success: true },
728 | };
729 | httpClient.post.mockResolvedValue(response);
730 |
731 | await client.bindClient({ user: testUser, company }).track(event.event, {
732 | attributes: event.attrs,
733 | meta: { active: true },
734 | });
735 |
736 | await client.flush();
737 | expect(httpClient.post).toHaveBeenCalledWith(
738 | BULK_ENDPOINT,
739 | expectedHeaders,
740 | [
741 | expect.objectContaining({
742 | type: "company",
743 | }),
744 | expect.objectContaining({
745 | type: "user",
746 | }),
747 | {
748 | attributes: {
749 | key: "value",
750 | },
751 | context: {
752 | active: true,
753 | },
754 | event: "flag-event",
755 | type: "event",
756 | userId: testUser.id,
757 | companyId: company.id,
758 | },
759 | ],
760 | );
761 |
762 | expect(logger.debug).toHaveBeenCalledWith(
763 | expect.stringMatching("post request to"),
764 | response,
765 | );
766 | });
767 |
768 | it("should successfully track the flag usage including user and company", async () => {
769 | httpClient.post.mockResolvedValue({
770 | status: 200,
771 | body: { success: true },
772 | });
773 |
774 | await client.bindClient({ user }).track(event.event, {
775 | companyId: "otherCompanyId",
776 | attributes: event.attrs,
777 | meta: { active: true },
778 | });
779 |
780 | await client.flush();
781 | expect(httpClient.post).toHaveBeenCalledWith(
782 | BULK_ENDPOINT,
783 | expectedHeaders,
784 | [
785 | expect.objectContaining({
786 | type: "user",
787 | }),
788 | {
789 | attributes: {
790 | key: "value",
791 | },
792 | context: {
793 | active: true,
794 | },
795 | event: "flag-event",
796 | companyId: "otherCompanyId",
797 | type: "event",
798 | userId: "user123",
799 | },
800 | ],
801 | );
802 | });
803 |
804 | it("should log an error if the post request fails", async () => {
805 | const error = new Error("Network error");
806 | httpClient.post.mockRejectedValue(error);
807 |
808 | await client.bindClient({ user }).track(event.event);
809 | await client.flush();
810 |
811 | expect(logger.error).toHaveBeenCalledWith(
812 | expect.stringMatching("post request to .* failed with error"),
813 | error,
814 | );
815 | });
816 |
817 | it("should log if the API call returns false", async () => {
818 | const response = {
819 | status: 200,
820 | body: { success: false },
821 | };
822 |
823 | httpClient.post.mockResolvedValue(response);
824 |
825 | await client.bindClient({ user }).track(event.event);
826 | await client.flush();
827 |
828 | expect(logger.warn).toHaveBeenCalledWith(
829 | expect.stringMatching("invalid response received from server for "),
830 | JSON.stringify(response),
831 | );
832 | });
833 |
834 | it("should log if user is not set", async () => {
835 | const boundClient = client.bindClient({ company });
836 |
837 | await boundClient.track("hello");
838 |
839 | expect(httpClient.post).not.toHaveBeenCalled();
840 | expect(logger.warn).toHaveBeenCalledWith(
841 | expect.stringMatching("no user set, cannot track event"),
842 | );
843 | });
844 |
845 | it("should throw an error if event is invalid", async () => {
846 | const boundClient = client.bindClient({ company, user });
847 |
848 | await expect(boundClient.track(undefined as any)).rejects.toThrow(
849 | "event must be a string",
850 | );
851 | await expect(boundClient.track(1 as any)).rejects.toThrow(
852 | "event must be a string",
853 | );
854 |
855 | await expect(
856 | boundClient.track(event.event, "bad_opts" as any),
857 | ).rejects.toThrow("validation failed: options must be an object");
858 |
859 | await expect(
860 | boundClient.track(event.event, {
861 | attributes: "bad_attributes" as any,
862 | }),
863 | ).rejects.toThrow("attributes must be an object");
864 |
865 | await expect(
866 | boundClient.track(event.event, { meta: "bad_meta" as any }),
867 | ).rejects.toThrow("meta must be an object");
868 | });
869 | });
870 |
871 | describe("user", () => {
872 | it("should return the undefined if user was not set", () => {
873 | const client = new ReflagClient(validOptions).bindClient({ company });
874 | expect(client.user).toBeUndefined();
875 | });
876 |
877 | it("should return the user if user was associated", () => {
878 | const client = new ReflagClient(validOptions).bindClient({ user });
879 |
880 | expect(client.user).toEqual(user);
881 | });
882 | });
883 |
884 | describe("company", () => {
885 | it("should return the undefined if company was not set", () => {
886 | const client = new ReflagClient(validOptions).bindClient({ user });
887 | expect(client.company).toBeUndefined();
888 | });
889 |
890 | it("should return the user if company was associated", () => {
891 | const client = new ReflagClient(validOptions).bindClient({ company });
892 |
893 | expect(client.company).toEqual(company);
894 | });
895 | });
896 |
897 | describe("otherContext", () => {
898 | it("should return the undefined if custom context was not set", () => {
899 | const client = new ReflagClient(validOptions).bindClient({ company });
900 | expect(client.otherContext).toBeUndefined();
901 | });
902 |
903 | it("should return the user if custom context was associated", () => {
904 | const client = new ReflagClient(validOptions).bindClient({
905 | other: otherContext,
906 | });
907 |
908 | expect(client.otherContext).toEqual(otherContext);
909 | });
910 | });
911 |
912 | describe("initialize", () => {
913 | it("should initialize the client", async () => {
914 | const client = new ReflagClient(validOptions);
915 |
916 | const get = vi
917 | .spyOn(client["flagsCache"], "get")
918 | .mockReturnValue(undefined);
919 | const refresh = vi
920 | .spyOn(client["flagsCache"], "refresh")
921 | .mockResolvedValue(undefined);
922 |
923 | await client.initialize();
924 | await client.initialize();
925 | await client.initialize();
926 |
927 | expect(refresh).toHaveBeenCalledTimes(1);
928 | expect(get).not.toHaveBeenCalled();
929 | });
930 |
931 | it("should call the backend to obtain flags", async () => {
932 | const client = new ReflagClient(validOptions);
933 |
934 | httpClient.get.mockResolvedValue({
935 | ok: true,
936 | status: 200,
937 | });
938 |
939 | await client.initialize();
940 |
941 | expect(httpClient.get).toHaveBeenCalledWith(
942 | `https://api.example.com/features`,
943 | expectedHeaders,
944 | API_TIMEOUT_MS,
945 | );
946 | });
947 | });
948 |
949 | describe("flush", () => {
950 | it("should flush all bulk data", async () => {
951 | const client = new ReflagClient(validOptions);
952 |
953 | await client.updateUser(user.id, { attributes: { age: 2 } });
954 | await client.updateUser(user.id, { attributes: { age: 3 } });
955 | await client.updateUser(user.id, { attributes: { name: "Jane" } });
956 |
957 | await client.flush();
958 |
959 | expect(httpClient.post).toHaveBeenCalledWith(
960 | BULK_ENDPOINT,
961 | expectedHeaders,
962 | [
963 | {
964 | type: "user",
965 | userId: user.id,
966 | attributes: { age: 2 },
967 | },
968 | {
969 | type: "user",
970 | userId: user.id,
971 | attributes: { age: 3 },
972 | },
973 | {
974 | type: "user",
975 | userId: user.id,
976 | attributes: { name: "Jane" },
977 | },
978 | ],
979 | );
980 | });
981 |
982 | it("should not flush all bulk data if `offline` is true", async () => {
983 | const client = new ReflagClient({
984 | ...validOptions,
985 | offline: true,
986 | });
987 |
988 | await client.updateUser(user.id, { attributes: { age: 2 } });
989 | await client.flush();
990 |
991 | expect(httpClient.post).not.toHaveBeenCalled();
992 | });
993 | });
994 |
995 | describe("getFlag", () => {
996 | let client: ReflagClient;
997 |
998 | beforeEach(async () => {
999 | httpClient.get.mockResolvedValue({
1000 | ok: true,
1001 | status: 200,
1002 | body: {
1003 | success: true,
1004 | ...flagDefinitions,
1005 | },
1006 | });
1007 |
1008 | client = new ReflagClient(validOptions);
1009 |
1010 | httpClient.post.mockResolvedValue({
1011 | status: 200,
1012 | body: { success: true },
1013 | });
1014 | });
1015 |
1016 | it("returns a flag", async () => {
1017 | await client.initialize();
1018 | const flag = client.getFlag(
1019 | {
1020 | company,
1021 | user,
1022 | other: otherContext,
1023 | },
1024 | "flag1",
1025 | );
1026 |
1027 | expect(flag).toStrictEqual({
1028 | key: "flag1",
1029 | isEnabled: true,
1030 | config: {
1031 | key: "config-1",
1032 | payload: { something: "else" },
1033 | },
1034 | track: expect.any(Function),
1035 | });
1036 | });
1037 |
1038 | it("`track` sends all expected events when `enableTracking` is `true`", async () => {
1039 | const context = {
1040 | company,
1041 | user,
1042 | other: otherContext,
1043 | };
1044 |
1045 | // test that the flag is returned
1046 | await client.initialize();
1047 | const flag = client.getFlag(
1048 | {
1049 | ...context,
1050 | meta: {
1051 | active: true,
1052 | },
1053 | enableTracking: true,
1054 | },
1055 | "flag1",
1056 | );
1057 |
1058 | await flag.track();
1059 | await client.flush();
1060 |
1061 | expect(httpClient.post).toHaveBeenCalledWith(
1062 | BULK_ENDPOINT,
1063 | expectedHeaders,
1064 | [
1065 | {
1066 | attributes: {
1067 | employees: 100,
1068 | name: "Acme Inc.",
1069 | },
1070 | companyId: "company123",
1071 | context: {
1072 | active: true,
1073 | },
1074 | type: "company",
1075 | userId: undefined,
1076 | },
1077 | {
1078 | attributes: {
1079 | age: 1,
1080 | name: "John",
1081 | },
1082 | context: {
1083 | active: true,
1084 | },
1085 | type: "user",
1086 | userId: "user123",
1087 | },
1088 | {
1089 | type: "event",
1090 | event: "flag1",
1091 | userId: user.id,
1092 | companyId: company.id,
1093 | },
1094 | ],
1095 | );
1096 | });
1097 |
1098 | it("`isEnabled` sends `check` event", async () => {
1099 | const context = {
1100 | company,
1101 | user,
1102 | other: otherContext,
1103 | };
1104 |
1105 | // test that the flag is returned
1106 | await client.initialize();
1107 | const flag = client.getFlag(context, "flag1");
1108 |
1109 | // trigger `check` event
1110 | expect(flag.isEnabled).toBe(true);
1111 |
1112 | await client.flush();
1113 | const checkEvents = httpClient.post.mock.calls
1114 | .flatMap((call) => call[2])
1115 | .filter((e) => e.action === "check");
1116 |
1117 | expect(checkEvents).toStrictEqual([
1118 | {
1119 | type: "feature-flag-event",
1120 | action: "check",
1121 | key: "flag1",
1122 | targetingVersion: 1,
1123 | evalResult: true,
1124 | evalContext: context,
1125 | evalRuleResults: [true],
1126 | evalMissingFields: [],
1127 | },
1128 | ]);
1129 | });
1130 |
1131 | it("`isEnabled` warns about missing context fields", async () => {
1132 | const context = {
1133 | company,
1134 | user,
1135 | other: otherContext,
1136 | };
1137 |
1138 | // test that the flag is returned
1139 | await client.initialize();
1140 | const flag = client.getFlag(context, "flag2");
1141 |
1142 | // trigger the warning
1143 | expect(flag.isEnabled).toBe(false);
1144 |
1145 | expect(logger.warn).toHaveBeenCalledWith(
1146 | "flag targeting rules might not be correctly evaluated due to missing context fields.",
1147 | {
1148 | flag2: ["attributeKey"],
1149 | },
1150 | );
1151 | });
1152 |
1153 | it("`isEnabled` should not warn about missing context fields if not needed", async () => {
1154 | const context = {
1155 | company,
1156 | user,
1157 | other: otherContext,
1158 | };
1159 |
1160 | // test that the flag is returned
1161 | await client.initialize();
1162 | const flag = client.getFlag(context, "flag1");
1163 |
1164 | // should not trigger the warning
1165 | expect(flag.isEnabled).toBe(true);
1166 |
1167 | expect(logger.warn).not.toHaveBeenCalled();
1168 | });
1169 |
1170 | it("`config` sends `check` event", async () => {
1171 | const context = {
1172 | company,
1173 | user,
1174 | other: otherContext,
1175 | };
1176 |
1177 | // test that the flag is returned
1178 | await client.initialize();
1179 | const flag = client.getFlag(context, "flag1");
1180 |
1181 | // trigger `check` event
1182 | expect(flag.config).toBeDefined();
1183 |
1184 | await client.flush();
1185 |
1186 | const checkEvents = httpClient.post.mock.calls
1187 | .flatMap((call) => call[2])
1188 | .filter((e) => e.action === "check-config");
1189 |
1190 | expect(checkEvents).toStrictEqual([
1191 | {
1192 | type: "feature-flag-event",
1193 | action: "check-config",
1194 | key: "flag1",
1195 | evalResult: {
1196 | key: "config-1",
1197 | payload: {
1198 | something: "else",
1199 | },
1200 | },
1201 | targetingVersion: 1,
1202 | evalContext: context,
1203 | evalRuleResults: [true],
1204 | evalMissingFields: [],
1205 | },
1206 | ]);
1207 | });
1208 |
1209 | it("sends events for unknown flags", async () => {
1210 | const context: Context = {
1211 | company,
1212 | user,
1213 | other: otherContext,
1214 | };
1215 |
1216 | // test that the flag is returned
1217 | await client.initialize();
1218 | const flag = client.getFlag(context, "unknown-flag");
1219 |
1220 | // trigger `check` event
1221 | expect(flag.isEnabled).toBe(false);
1222 | await flag.track();
1223 | await client.flush();
1224 |
1225 | const checkEvents = httpClient.post.mock.calls
1226 | .flatMap((call) => call[2])
1227 | .filter((e) => e.type === "feature-flag-event");
1228 |
1229 | expect(checkEvents).toStrictEqual([
1230 | {
1231 | type: "feature-flag-event",
1232 | action: "check",
1233 | key: "unknown-flag",
1234 | targetingVersion: undefined,
1235 | evalContext: context,
1236 | evalResult: false,
1237 | evalRuleResults: undefined,
1238 | evalMissingFields: undefined,
1239 | },
1240 | ]);
1241 | });
1242 |
1243 | it("sends company/user and track events", async () => {
1244 | const context: Context = {
1245 | company,
1246 | user,
1247 | other: otherContext,
1248 | };
1249 |
1250 | // test that the flag is returned
1251 | await client.initialize();
1252 | const flag = client.getFlag(context, "flag1");
1253 |
1254 | // trigger `check` event
1255 | await flag.track();
1256 | await client.flush();
1257 |
1258 | const checkEvents = httpClient.post.mock.calls
1259 | .flatMap((call) => call[2])
1260 | .filter(
1261 | (e) =>
1262 | e.type === "company" || e.type === "user" || e.type === "event",
1263 | );
1264 |
1265 | expect(checkEvents).toStrictEqual([
1266 | {
1267 | type: "company",
1268 | companyId: "company123",
1269 | attributes: {
1270 | employees: 100,
1271 | name: "Acme Inc.",
1272 | },
1273 | userId: undefined, // this is a bug, will fix in separate PR
1274 | context: undefined,
1275 | },
1276 | {
1277 | type: "user",
1278 | userId: "user123",
1279 | attributes: {
1280 | age: 1,
1281 | name: "John",
1282 | },
1283 | context: undefined,
1284 | },
1285 | {
1286 | type: "event",
1287 | event: "flag1",
1288 | userId: user.id,
1289 | companyId: company.id,
1290 | context: undefined,
1291 | attributes: undefined,
1292 | },
1293 | ]);
1294 | });
1295 | });
1296 |
1297 | describe("getFlags", () => {
1298 | let client: ReflagClient;
1299 |
1300 | beforeEach(async () => {
1301 | httpClient.get.mockResolvedValue({
1302 | ok: true,
1303 | status: 200,
1304 | body: {
1305 | success: true,
1306 | ...flagDefinitions,
1307 | },
1308 | });
1309 |
1310 | client = new ReflagClient(validOptions);
1311 |
1312 | client["rateLimiter"].clearStale(true);
1313 |
1314 | httpClient.post.mockResolvedValue({
1315 | ok: true,
1316 | status: 200,
1317 | body: { success: true },
1318 | });
1319 | });
1320 |
1321 | it("should return evaluated flags", async () => {
1322 | httpClient.post.mockClear(); // not interested in updates
1323 |
1324 | await client.initialize();
1325 | const result = client.getFlags({
1326 | company,
1327 | user,
1328 | other: otherContext,
1329 | });
1330 |
1331 | expect(result).toStrictEqual({
1332 | flag1: {
1333 | key: "flag1",
1334 | isEnabled: true,
1335 | config: {
1336 | key: "config-1",
1337 | payload: {
1338 | something: "else",
1339 | },
1340 | },
1341 | track: expect.any(Function),
1342 | },
1343 | flag2: {
1344 | key: "flag2",
1345 | isEnabled: false,
1346 | config: { key: undefined, payload: undefined },
1347 | track: expect.any(Function),
1348 | },
1349 | });
1350 |
1351 | await client.flush();
1352 |
1353 | expect(httpClient.post).toHaveBeenCalledTimes(1);
1354 | });
1355 |
1356 | it("should properly define the rate limiter key", async () => {
1357 | const isAllowedSpy = vi.spyOn(client["rateLimiter"], "isAllowed");
1358 |
1359 | await client.initialize();
1360 | client.getFlags({ user, company, other: otherContext });
1361 |
1362 | expect(isAllowedSpy).toHaveBeenCalledWith("1GHpP+QfYperQ0AtD8bWPiRE4H0=");
1363 | });
1364 |
1365 | it("should return evaluated flags when only user is defined", async () => {
1366 | httpClient.post.mockClear(); // not interested in updates
1367 |
1368 | await client.initialize();
1369 | const flags = client.getFlags({ user });
1370 |
1371 | expect(flags).toStrictEqual({
1372 | flag1: {
1373 | isEnabled: false,
1374 | key: "flag1",
1375 | config: {
1376 | key: undefined,
1377 | payload: undefined,
1378 | },
1379 | track: expect.any(Function),
1380 | },
1381 | flag2: {
1382 | key: "flag2",
1383 | isEnabled: false,
1384 | config: { key: undefined, payload: undefined },
1385 | track: expect.any(Function),
1386 | },
1387 | });
1388 |
1389 | await client.flush();
1390 |
1391 | expect(httpClient.post).toHaveBeenCalledTimes(1);
1392 | });
1393 |
1394 | it("should return evaluated flags when only company is defined", async () => {
1395 | await client.initialize();
1396 | const flags = client.getFlags({ company });
1397 |
1398 | // expect will trigger the `isEnabled` getter and send a `check` event
1399 | expect(flags).toStrictEqual({
1400 | flag1: {
1401 | isEnabled: true,
1402 | key: "flag1",
1403 | config: {
1404 | key: "config-1",
1405 | payload: {
1406 | something: "else",
1407 | },
1408 | },
1409 | track: expect.any(Function),
1410 | },
1411 | flag2: {
1412 | key: "flag2",
1413 | isEnabled: false,
1414 | config: { key: undefined, payload: undefined },
1415 | track: expect.any(Function),
1416 | },
1417 | });
1418 |
1419 | await client.flush();
1420 |
1421 | expect(httpClient.post).toHaveBeenCalledTimes(1);
1422 | });
1423 |
1424 | it("should not send flag events when `enableTracking` is `false`", async () => {
1425 | await client.initialize();
1426 | const flags = client.getFlags({ company, enableTracking: false });
1427 |
1428 | // expect will trigger the `isEnabled` getter and send a `check` event
1429 | expect(flags).toStrictEqual({
1430 | flag1: {
1431 | isEnabled: true,
1432 | key: "flag1",
1433 | config: {
1434 | key: "config-1",
1435 | payload: {
1436 | something: "else",
1437 | },
1438 | },
1439 | track: expect.any(Function),
1440 | },
1441 | flag2: {
1442 | key: "flag2",
1443 | isEnabled: false,
1444 | config: { key: undefined, payload: undefined },
1445 | track: expect.any(Function),
1446 | },
1447 | });
1448 |
1449 | await client.flush();
1450 |
1451 | expect(httpClient.post).not.toHaveBeenCalled();
1452 | });
1453 |
1454 | it("should return evaluated flags when only other context is defined", async () => {
1455 | await client.initialize();
1456 | const flags = client.getFlags({ other: otherContext });
1457 |
1458 | expect(flags).toStrictEqual({
1459 | flag1: {
1460 | isEnabled: false,
1461 | key: "flag1",
1462 | config: {
1463 | key: undefined,
1464 | payload: undefined,
1465 | },
1466 | track: expect.any(Function),
1467 | },
1468 | flag2: {
1469 | key: "flag2",
1470 | isEnabled: false,
1471 | config: { key: undefined, payload: undefined },
1472 | track: expect.any(Function),
1473 | },
1474 | });
1475 |
1476 | await client.flush();
1477 | });
1478 |
1479 | it("should send `track` with user and company if provided", async () => {
1480 | await client.initialize();
1481 | const flags = client.getFlags({ company, user });
1482 | await client.flush();
1483 |
1484 | await flags.flag1.track();
1485 | await client.flush();
1486 |
1487 | expect(httpClient.post).toHaveBeenCalledTimes(2);
1488 | // second call includes the track event
1489 | const events = httpClient.post.mock.calls[1][2].filter(
1490 | (e: any) => e.type === "event",
1491 | );
1492 |
1493 | expect(events).toStrictEqual([
1494 | {
1495 | event: "flag1",
1496 | type: "event",
1497 | userId: "user123",
1498 | companyId: "company123",
1499 | attributes: undefined,
1500 | context: undefined,
1501 | },
1502 | ]);
1503 | });
1504 |
1505 | it("should send `track` with user if provided", async () => {
1506 | await client.initialize();
1507 | const flags = client.getFlags({ user });
1508 |
1509 | await client.flush();
1510 | await flags.flag1.track();
1511 | await client.flush();
1512 |
1513 | expect(httpClient.post).toHaveBeenCalledTimes(2);
1514 |
1515 | const emptyEvents = httpClient.post.mock.calls[0][2].filter(
1516 | (e: any) => e.type === "event",
1517 | );
1518 |
1519 | expect(emptyEvents).toStrictEqual([]);
1520 |
1521 | // second call includes the track event
1522 | const events = httpClient.post.mock.calls[1][2].filter(
1523 | (e: any) => e.type === "event",
1524 | );
1525 |
1526 | expect(events).toStrictEqual([
1527 | {
1528 | event: "flag1",
1529 | type: "event",
1530 | userId: "user123",
1531 | companyId: undefined,
1532 | attributes: undefined,
1533 | context: undefined,
1534 | },
1535 | ]);
1536 | });
1537 |
1538 | it("should not send `track` with only company if no user is provided", async () => {
1539 | // we do not accept track events without a userId
1540 | await client.initialize();
1541 | const flag = client.getFlags({ company });
1542 |
1543 | await flag.flag1.track();
1544 | await client.flush();
1545 |
1546 | expect(httpClient.post).toHaveBeenCalledTimes(1);
1547 | const events = httpClient.post.mock.calls[0][2].filter(
1548 | (e: any) => e.type === "event",
1549 | );
1550 |
1551 | expect(events).toStrictEqual([]);
1552 | });
1553 |
1554 | it("`isEnabled` does not send `check` event", async () => {
1555 | const context = {
1556 | company,
1557 | user,
1558 | other: otherContext,
1559 | };
1560 |
1561 | // test that the flag is returned
1562 | await client.initialize();
1563 | const flag = client.getFlags(context);
1564 |
1565 | // trigger `check` event
1566 | expect(flag.flag1.isEnabled).toBe(true);
1567 |
1568 | await client.flush();
1569 | const checkEvents = httpClient.post.mock.calls
1570 | .flatMap((call) => call[2])
1571 | .filter((e) => e.action === "check");
1572 |
1573 | expect(checkEvents).toStrictEqual([]);
1574 | });
1575 |
1576 | it("`config` does not send `check` event", async () => {
1577 | const context = {
1578 | company,
1579 | user,
1580 | other: otherContext,
1581 | };
1582 |
1583 | // test that the flag is returned
1584 | await client.initialize();
1585 | const flag = client.getFlags(context);
1586 |
1587 | // attempt to trigger `check` event
1588 | expect(flag.flag1.config).toBeDefined();
1589 |
1590 | await client.flush();
1591 |
1592 | const checkEvents = httpClient.post.mock.calls
1593 | .flatMap((call) => call[2])
1594 | .filter((e) => e.action === "check-config");
1595 |
1596 | expect(checkEvents).toStrictEqual([]);
1597 | });
1598 |
1599 | it("sends company/user events", async () => {
1600 | const context: Context = {
1601 | company,
1602 | user,
1603 | other: otherContext,
1604 | };
1605 |
1606 | // test that the flag is returned
1607 | await client.initialize();
1608 | client.getFlags(context);
1609 |
1610 | // trigger `check` event
1611 | await client.flush();
1612 |
1613 | const checkEvents = httpClient.post.mock.calls
1614 | .flatMap((call) => call[2])
1615 | .filter(
1616 | (e) =>
1617 | e.type === "company" || e.type === "user" || e.type === "event",
1618 | );
1619 |
1620 | expect(checkEvents).toStrictEqual([
1621 | {
1622 | type: "company",
1623 | companyId: "company123",
1624 | attributes: {
1625 | employees: 100,
1626 | name: "Acme Inc.",
1627 | },
1628 | userId: undefined, // this is a bug, will fix in separate PR
1629 | context: undefined,
1630 | },
1631 | {
1632 | type: "user",
1633 | userId: "user123",
1634 | attributes: {
1635 | age: 1,
1636 | name: "John",
1637 | },
1638 | context: undefined,
1639 | },
1640 | ]);
1641 | });
1642 |
1643 | it("should use fallback flags when `getFlagDefinitions` returns `undefined`", async () => {
1644 | httpClient.get.mockResolvedValue({
1645 | success: false,
1646 | });
1647 |
1648 | await client.initialize();
1649 | const result = client.getFlag(
1650 | { user: { id: "user123" }, enableTracking: true },
1651 | "key",
1652 | );
1653 |
1654 | expect(result).toStrictEqual({
1655 | key: "key",
1656 | isEnabled: true,
1657 | config: { key: undefined, payload: undefined },
1658 | track: expect.any(Function),
1659 | });
1660 |
1661 | expect(logger.warn).toHaveBeenCalledWith(
1662 | expect.stringMatching(
1663 | "no flag definitions available, using fallback flags",
1664 | ),
1665 | );
1666 |
1667 | await client.flush();
1668 |
1669 | expect(httpClient.post).toHaveBeenCalledTimes(1);
1670 | expect(httpClient.post).toHaveBeenCalledWith(
1671 | BULK_ENDPOINT,
1672 | expectedHeaders,
1673 | [
1674 | expect.objectContaining({ type: "user" }),
1675 | expect.objectContaining({
1676 | type: "feature-flag-event",
1677 | action: "check-config",
1678 | key: "key",
1679 | }),
1680 | expect.objectContaining({
1681 | type: "feature-flag-event",
1682 | action: "check",
1683 | key: "key",
1684 | evalResult: true,
1685 | }),
1686 | ],
1687 | );
1688 | });
1689 |
1690 | it("should not fail if sendFlagEvent fails to send check event", async () => {
1691 | httpClient.post.mockResolvedValue({
1692 | status: 200,
1693 | body: { success: true },
1694 | });
1695 |
1696 | await client.initialize();
1697 | httpClient.post.mockRejectedValue(new Error("Network error"));
1698 | const context = { user, company, other: otherContext };
1699 |
1700 | const result = client.getFlags(context);
1701 |
1702 | // Trigger a flag check
1703 | expect(result.flag1).toStrictEqual({
1704 | key: "flag1",
1705 | isEnabled: true,
1706 | track: expect.any(Function),
1707 | config: {
1708 | key: "config-1",
1709 | payload: {
1710 | something: "else",
1711 | },
1712 | },
1713 | });
1714 |
1715 | await client.flush();
1716 |
1717 | expect(logger.error).toHaveBeenCalledWith(
1718 | expect.stringMatching("post request .* failed with error"),
1719 | expect.any(Error),
1720 | );
1721 | });
1722 |
1723 | it("should use flag overrides", async () => {
1724 | await client.initialize();
1725 | const context = { user, company, other: otherContext };
1726 |
1727 | const pristineResults = client.getFlags(context);
1728 | expect(pristineResults).toStrictEqual({
1729 | flag1: {
1730 | key: "flag1",
1731 | isEnabled: true,
1732 | config: {
1733 | key: "config-1",
1734 | payload: {
1735 | something: "else",
1736 | },
1737 | },
1738 | track: expect.any(Function),
1739 | },
1740 | flag2: {
1741 | key: "flag2",
1742 | isEnabled: false,
1743 | config: { key: undefined, payload: undefined },
1744 | track: expect.any(Function),
1745 | },
1746 | });
1747 |
1748 | client.flagOverrides = {
1749 | flag1: false,
1750 | };
1751 | const flags = client.getFlags(context);
1752 |
1753 | expect(flags).toStrictEqual({
1754 | flag1: {
1755 | key: "flag1",
1756 | isEnabled: false,
1757 | config: { key: undefined, payload: undefined },
1758 | track: expect.any(Function),
1759 | },
1760 | flag2: {
1761 | key: "flag2",
1762 | isEnabled: false,
1763 | config: { key: undefined, payload: undefined },
1764 | track: expect.any(Function),
1765 | },
1766 | });
1767 |
1768 | client.clearFlagOverrides();
1769 | const flags2 = client.getFlags(context);
1770 |
1771 | expect(flags2).toStrictEqual({
1772 | ...pristineResults,
1773 | flag1: {
1774 | ...pristineResults.flag1,
1775 | track: expect.any(Function),
1776 | },
1777 | flag2: {
1778 | ...pristineResults.flag2,
1779 | track: expect.any(Function),
1780 | },
1781 | });
1782 | });
1783 |
1784 | it("should use flag overrides from function", async () => {
1785 | await client.initialize();
1786 | const context = { user, company, other: otherContext };
1787 |
1788 | const pristineResults = client.getFlags(context);
1789 | expect(pristineResults).toStrictEqual({
1790 | flag1: {
1791 | key: "flag1",
1792 | isEnabled: true,
1793 | config: {
1794 | key: "config-1",
1795 | payload: {
1796 | something: "else",
1797 | },
1798 | },
1799 | track: expect.any(Function),
1800 | },
1801 | flag2: {
1802 | key: "flag2",
1803 | isEnabled: false,
1804 | config: { key: undefined, payload: undefined },
1805 | track: expect.any(Function),
1806 | },
1807 | });
1808 |
1809 | client.flagOverrides = (_context: Context) => {
1810 | expect(context).toStrictEqual(context);
1811 | return {
1812 | flag1: { isEnabled: false },
1813 | flag2: true,
1814 | flag3: {
1815 | isEnabled: true,
1816 | config: {
1817 | key: "config-1",
1818 | payload: { something: "else" },
1819 | },
1820 | },
1821 | };
1822 | };
1823 | const flags = client.getFlags(context);
1824 |
1825 | expect(flags).toStrictEqual({
1826 | flag1: {
1827 | key: "flag1",
1828 | isEnabled: false,
1829 | config: { key: undefined, payload: undefined },
1830 | track: expect.any(Function),
1831 | },
1832 | flag2: {
1833 | key: "flag2",
1834 | isEnabled: true,
1835 | config: { key: undefined, payload: undefined },
1836 | track: expect.any(Function),
1837 | },
1838 | flag3: {
1839 | key: "flag3",
1840 | isEnabled: true,
1841 | config: {
1842 | key: "config-1",
1843 | payload: { something: "else" },
1844 | },
1845 | track: expect.any(Function),
1846 | },
1847 | });
1848 | });
1849 | });
1850 |
1851 | describe("getFlagsForBootstrap", () => {
1852 | let client: ReflagClient;
1853 |
1854 | beforeEach(async () => {
1855 | httpClient.get.mockResolvedValue({
1856 | ok: true,
1857 | status: 200,
1858 | body: {
1859 | success: true,
1860 | ...flagDefinitions,
1861 | },
1862 | });
1863 |
1864 | client = new ReflagClient(validOptions);
1865 |
1866 | client["rateLimiter"].clearStale(true);
1867 |
1868 | httpClient.post.mockResolvedValue({
1869 | ok: true,
1870 | status: 200,
1871 | body: { success: true },
1872 | });
1873 | });
1874 |
1875 | it("should return raw flags without wrapper functions", async () => {
1876 | httpClient.post.mockClear(); // not interested in updates
1877 |
1878 | await client.initialize();
1879 | const result = client.getFlagsForBootstrap({
1880 | company,
1881 | user,
1882 | other: otherContext,
1883 | enableTracking: true,
1884 | });
1885 |
1886 | expect(result).toStrictEqual({
1887 | context: {
1888 | company,
1889 | user,
1890 | other: otherContext,
1891 | enableTracking: true,
1892 | },
1893 | flags: {
1894 | flag1: {
1895 | key: "flag1",
1896 | isEnabled: true,
1897 | targetingVersion: 1,
1898 | config: {
1899 | key: "config-1",
1900 | payload: {
1901 | something: "else",
1902 | },
1903 | targetingVersion: 1,
1904 | missingContextFields: [],
1905 | ruleEvaluationResults: [true],
1906 | },
1907 | ruleEvaluationResults: [true],
1908 | missingContextFields: [],
1909 | },
1910 | flag2: {
1911 | key: "flag2",
1912 | isEnabled: false,
1913 | targetingVersion: 2,
1914 | config: {
1915 | key: undefined,
1916 | payload: undefined,
1917 | targetingVersion: undefined,
1918 | missingContextFields: [],
1919 | ruleEvaluationResults: [],
1920 | },
1921 | ruleEvaluationResults: [false],
1922 | missingContextFields: ["attributeKey"],
1923 | },
1924 | },
1925 | });
1926 |
1927 | // Should not have track function like regular getFlags
1928 | expect(result.flags.flag1).not.toHaveProperty("track");
1929 | expect(result.flags.flag2).not.toHaveProperty("track");
1930 |
1931 | await client.flush();
1932 |
1933 | expect(httpClient.post).toHaveBeenCalledTimes(1);
1934 | });
1935 |
1936 | it("should return raw flags when only user is defined", async () => {
1937 | httpClient.post.mockClear(); // not interested in updates
1938 |
1939 | await client.initialize();
1940 | const flags = client.getFlagsForBootstrap({ user });
1941 |
1942 | expect(flags).toStrictEqual({
1943 | context: {
1944 | user,
1945 | enableTracking: true,
1946 | },
1947 | flags: {
1948 | flag1: {
1949 | key: "flag1",
1950 | isEnabled: false,
1951 | targetingVersion: 1,
1952 | config: {
1953 | key: undefined,
1954 | payload: undefined,
1955 | targetingVersion: 1,
1956 | missingContextFields: ["company.id"],
1957 | ruleEvaluationResults: [false],
1958 | },
1959 | ruleEvaluationResults: [false],
1960 | missingContextFields: ["company.id"],
1961 | },
1962 | flag2: {
1963 | key: "flag2",
1964 | isEnabled: false,
1965 | targetingVersion: 2,
1966 | config: {
1967 | key: undefined,
1968 | payload: undefined,
1969 | targetingVersion: undefined,
1970 | missingContextFields: [],
1971 | ruleEvaluationResults: [],
1972 | },
1973 | ruleEvaluationResults: [false],
1974 | missingContextFields: ["company.id"],
1975 | },
1976 | },
1977 | });
1978 |
1979 | // Should not have track function
1980 | expect(flags.flags.flag1).not.toHaveProperty("track");
1981 | expect(flags.flags.flag2).not.toHaveProperty("track");
1982 |
1983 | await client.flush();
1984 |
1985 | expect(httpClient.post).toHaveBeenCalledTimes(1);
1986 | });
1987 |
1988 | it("should return raw flags when only company is defined", async () => {
1989 | await client.initialize();
1990 | const flags = client.getFlagsForBootstrap({ company });
1991 |
1992 | expect(flags).toStrictEqual({
1993 | context: {
1994 | company,
1995 | enableTracking: true,
1996 | },
1997 | flags: {
1998 | flag1: {
1999 | key: "flag1",
2000 | isEnabled: true,
2001 | targetingVersion: 1,
2002 | config: {
2003 | key: "config-1",
2004 | payload: {
2005 | something: "else",
2006 | },
2007 | targetingVersion: 1,
2008 | missingContextFields: [],
2009 | ruleEvaluationResults: [true],
2010 | },
2011 | ruleEvaluationResults: [true],
2012 | missingContextFields: [],
2013 | },
2014 | flag2: {
2015 | key: "flag2",
2016 | isEnabled: false,
2017 | targetingVersion: 2,
2018 | config: {
2019 | key: undefined,
2020 | payload: undefined,
2021 | targetingVersion: undefined,
2022 | missingContextFields: [],
2023 | ruleEvaluationResults: [],
2024 | },
2025 | ruleEvaluationResults: [false],
2026 | missingContextFields: ["attributeKey"],
2027 | },
2028 | },
2029 | });
2030 |
2031 | // Should not have track function
2032 | expect(flags.flags.flag1).not.toHaveProperty("track");
2033 | expect(flags.flags.flag2).not.toHaveProperty("track");
2034 | });
2035 |
2036 | it("should return raw flags when only other context is defined", async () => {
2037 | await client.initialize();
2038 | const flags = client.getFlagsForBootstrap({ other: otherContext });
2039 |
2040 | expect(flags).toStrictEqual({
2041 | context: {
2042 | other: otherContext,
2043 | enableTracking: true,
2044 | },
2045 | flags: {
2046 | flag1: {
2047 | key: "flag1",
2048 | isEnabled: false,
2049 | targetingVersion: 1,
2050 | config: {
2051 | key: undefined,
2052 | payload: undefined,
2053 | targetingVersion: 1,
2054 | missingContextFields: ["company.id"],
2055 | ruleEvaluationResults: [false],
2056 | },
2057 | ruleEvaluationResults: [false],
2058 | missingContextFields: ["company.id"],
2059 | },
2060 | flag2: {
2061 | key: "flag2",
2062 | isEnabled: false,
2063 | targetingVersion: 2,
2064 | config: {
2065 | key: undefined,
2066 | payload: undefined,
2067 | targetingVersion: undefined,
2068 | missingContextFields: [],
2069 | ruleEvaluationResults: [],
2070 | },
2071 | ruleEvaluationResults: [false],
2072 | missingContextFields: ["company.id"],
2073 | },
2074 | },
2075 | });
2076 |
2077 | // Should not have track function
2078 | expect(flags.flags.flag1).not.toHaveProperty("track");
2079 | expect(flags.flags.flag2).not.toHaveProperty("track");
2080 | });
2081 |
2082 | it("should return fallback flags when client is not initialized", async () => {
2083 | const flags = client.getFlagsForBootstrap({
2084 | company,
2085 | user,
2086 | other: otherContext,
2087 | enableTracking: true,
2088 | });
2089 |
2090 | // Should return the fallback flags defined in validOptions
2091 | expect(flags).toStrictEqual({
2092 | context: {
2093 | company,
2094 | user,
2095 | other: otherContext,
2096 | enableTracking: true,
2097 | },
2098 | flags: {
2099 | key: {
2100 | isEnabled: true,
2101 | key: "key",
2102 | },
2103 | },
2104 | });
2105 | });
2106 |
2107 | it("should return fallback flags when flag definitions are not available", async () => {
2108 | httpClient.get.mockResolvedValueOnce({
2109 | ok: true,
2110 | status: 200,
2111 | body: {
2112 | success: true,
2113 | features: [], // No flag definitions
2114 | },
2115 | });
2116 |
2117 | await client.initialize();
2118 | const flags = client.getFlagsForBootstrap({
2119 | company,
2120 | user,
2121 | other: otherContext,
2122 | });
2123 |
2124 | expect(flags).toStrictEqual({
2125 | context: {
2126 | company,
2127 | user,
2128 | other: otherContext,
2129 | enableTracking: true,
2130 | },
2131 | flags: {},
2132 | });
2133 | });
2134 |
2135 | it("should handle enableTracking parameter", async () => {
2136 | await client.initialize();
2137 |
2138 | // Test with enableTracking: true (default)
2139 | const flagsWithTracking = client.getFlagsForBootstrap({
2140 | company,
2141 | user,
2142 | other: otherContext,
2143 | enableTracking: true,
2144 | });
2145 |
2146 | // Test with enableTracking: false
2147 | const flagsWithoutTracking = client.getFlagsForBootstrap({
2148 | company,
2149 | user,
2150 | other: otherContext,
2151 | enableTracking: true,
2152 | });
2153 |
2154 | // Both should return the same raw flag structure
2155 | expect(flagsWithTracking).toStrictEqual(flagsWithoutTracking);
2156 |
2157 | // Neither should have track functions
2158 | expect(flagsWithTracking.flags.flag1).not.toHaveProperty("track");
2159 | expect(flagsWithoutTracking.flags.flag1).not.toHaveProperty("track");
2160 | });
2161 |
2162 | it("should properly define the rate limiter key", async () => {
2163 | const isAllowedSpy = vi.spyOn(client["rateLimiter"], "isAllowed");
2164 |
2165 | await client.initialize();
2166 | client.getFlagsForBootstrap({ user, company, other: otherContext });
2167 |
2168 | expect(isAllowedSpy).toHaveBeenCalledWith("1GHpP+QfYperQ0AtD8bWPiRE4H0=");
2169 | });
2170 |
2171 | it("should work in offline mode", async () => {
2172 | const offlineClient = new ReflagClient({
2173 | ...validOptions,
2174 | offline: true,
2175 | });
2176 |
2177 | const flags = offlineClient.getFlagsForBootstrap({
2178 | company,
2179 | user,
2180 | other: otherContext,
2181 | enableTracking: true,
2182 | });
2183 |
2184 | expect(flags).toStrictEqual({
2185 | context: {
2186 | company,
2187 | user,
2188 | other: otherContext,
2189 | enableTracking: true,
2190 | },
2191 | flags: {},
2192 | });
2193 | });
2194 |
2195 | it("should use fallback flags when provided and no definitions available", async () => {
2196 | const fallbackTestFlags = {
2197 | fallbackFlag: {
2198 | key: "fallbackFlag",
2199 | isEnabled: true,
2200 | config: { key: "fallback-config", payload: { test: "data" } },
2201 | },
2202 | };
2203 |
2204 | const clientWithFallback = new ReflagClient({
2205 | ...validOptions,
2206 | fallbackFlags: fallbackTestFlags,
2207 | });
2208 |
2209 | // Don't initialize to simulate no flag definitions
2210 | const flags = clientWithFallback.getFlagsForBootstrap({
2211 | company,
2212 | user,
2213 | other: otherContext,
2214 | enableTracking: true,
2215 | });
2216 |
2217 | expect(flags).toStrictEqual({
2218 | context: {
2219 | company,
2220 | user,
2221 | other: otherContext,
2222 | enableTracking: true,
2223 | },
2224 | flags: fallbackTestFlags,
2225 | });
2226 | });
2227 | });
2228 | });
2229 |
2230 | describe("getFlagsRemote", () => {
2231 | let client: ReflagClient;
2232 |
2233 | beforeEach(async () => {
2234 | httpClient.get.mockResolvedValue({
2235 | ok: true,
2236 | status: 200,
2237 | body: {
2238 | success: true,
2239 | remoteContextUsed: true,
2240 | features: {
2241 | flag1: {
2242 | key: "flag1",
2243 | targetingVersion: 1,
2244 | isEnabled: true,
2245 | config: {
2246 | key: "config-1",
2247 | version: 3,
2248 | default: true,
2249 | payload: { something: "else" },
2250 | missingContextFields: ["funny"],
2251 | },
2252 | missingContextFields: ["something", "funny"],
2253 | },
2254 | flag2: {
2255 | key: "flag2",
2256 | targetingVersion: 2,
2257 | isEnabled: false,
2258 | missingContextFields: ["another"],
2259 | },
2260 | flag3: {
2261 | key: "flag3",
2262 | targetingVersion: 5,
2263 | isEnabled: true,
2264 | },
2265 | },
2266 | },
2267 | });
2268 |
2269 | client = new ReflagClient(validOptions);
2270 | });
2271 |
2272 | afterEach(() => {
2273 | httpClient.get.mockClear();
2274 | });
2275 |
2276 | it("should return evaluated flags", async () => {
2277 | const result = await client.getFlagsRemote("c1", "u1", {
2278 | other: otherContext,
2279 | });
2280 |
2281 | expect(result).toStrictEqual({
2282 | flag1: {
2283 | key: "flag1",
2284 | isEnabled: true,
2285 | config: {
2286 | key: "config-1",
2287 | payload: { something: "else" },
2288 | },
2289 | track: expect.any(Function),
2290 | },
2291 | flag2: {
2292 | key: "flag2",
2293 | isEnabled: false,
2294 | config: { key: undefined, payload: undefined },
2295 | track: expect.any(Function),
2296 | },
2297 | flag3: {
2298 | key: "flag3",
2299 | isEnabled: true,
2300 | config: { key: undefined, payload: undefined },
2301 | track: expect.any(Function),
2302 | },
2303 | });
2304 |
2305 | expect(httpClient.get).toHaveBeenCalledTimes(1);
2306 |
2307 | expect(httpClient.get).toHaveBeenCalledWith(
2308 | "https://api.example.com/features/evaluated?context.other.custom=context&context.other.key=value&context.user.id=c1&context.company.id=u1",
2309 | expectedHeaders,
2310 | API_TIMEOUT_MS,
2311 | );
2312 | });
2313 |
2314 | it("should not try to append the context if it's empty", async () => {
2315 | await client.getFlagsRemote();
2316 |
2317 | expect(httpClient.get).toHaveBeenCalledTimes(1);
2318 |
2319 | expect(httpClient.get).toHaveBeenCalledWith(
2320 | "https://api.example.com/features/evaluated?",
2321 | expectedHeaders,
2322 | API_TIMEOUT_MS,
2323 | );
2324 | });
2325 | });
2326 |
2327 | describe("getFlagRemote", () => {
2328 | let client: ReflagClient;
2329 |
2330 | beforeEach(async () => {
2331 | httpClient.get.mockResolvedValue({
2332 | ok: true,
2333 | status: 200,
2334 | body: {
2335 | success: true,
2336 | remoteContextUsed: true,
2337 | features: {
2338 | flag1: {
2339 | key: "flag1",
2340 | targetingVersion: 1,
2341 | isEnabled: true,
2342 | config: {
2343 | key: "config-1",
2344 | version: 3,
2345 | default: true,
2346 | payload: { something: "else" },
2347 | missingContextFields: ["two"],
2348 | },
2349 | missingContextFields: ["one", "two"],
2350 | },
2351 | },
2352 | },
2353 | });
2354 |
2355 | client = new ReflagClient(validOptions);
2356 | });
2357 |
2358 | afterEach(() => {
2359 | httpClient.get.mockClear();
2360 | });
2361 |
2362 | it("should return evaluated flag", async () => {
2363 | const result = await client.getFlagRemote("flag1", "c1", "u1", {
2364 | other: otherContext,
2365 | });
2366 |
2367 | expect(result).toStrictEqual({
2368 | key: "flag1",
2369 | isEnabled: true,
2370 | track: expect.any(Function),
2371 | config: {
2372 | key: "config-1",
2373 | payload: { something: "else" },
2374 | },
2375 | });
2376 |
2377 | expect(httpClient.get).toHaveBeenCalledTimes(1);
2378 |
2379 | expect(httpClient.get).toHaveBeenCalledWith(
2380 | "https://api.example.com/features/evaluated?context.other.custom=context&context.other.key=value&context.user.id=c1&context.company.id=u1&key=flag1",
2381 | expectedHeaders,
2382 | API_TIMEOUT_MS,
2383 | );
2384 | });
2385 |
2386 | it("should not try to append the context if it's empty", async () => {
2387 | await client.getFlagRemote("flag1");
2388 |
2389 | expect(httpClient.get).toHaveBeenCalledWith(
2390 | "https://api.example.com/features/evaluated?key=flag1",
2391 | expectedHeaders,
2392 | API_TIMEOUT_MS,
2393 | );
2394 | });
2395 | });
2396 |
2397 | describe("offline mode", () => {
2398 | let client: ReflagClient;
2399 |
2400 | beforeEach(async () => {
2401 | client = new ReflagClient({
2402 | ...validOptions,
2403 | offline: true,
2404 | });
2405 | await client.initialize();
2406 | });
2407 |
2408 | it("should send not send or fetch anything", async () => {
2409 | client.getFlags({});
2410 |
2411 | expect(httpClient.get).toHaveBeenCalledTimes(0);
2412 | expect(httpClient.post).toHaveBeenCalledTimes(0);
2413 | });
2414 | });
2415 |
2416 | describe("BoundReflagClient", () => {
2417 | beforeAll(() => {
2418 | const response = {
2419 | status: 200,
2420 | body: { success: true },
2421 | };
2422 |
2423 | httpClient.post.mockResolvedValue(response);
2424 |
2425 | httpClient.get.mockResolvedValue({
2426 | ok: true,
2427 | status: 200,
2428 | body: {
2429 | success: true,
2430 | ...flagDefinitions,
2431 | },
2432 | });
2433 | });
2434 | const client = new ReflagClient(validOptions);
2435 |
2436 | beforeEach(async () => {
2437 | await flushPromises();
2438 | await client.flush();
2439 |
2440 | vi.mocked(httpClient.post).mockClear();
2441 | client["rateLimiter"].clearStale(true);
2442 | });
2443 |
2444 | it("should create a client instance", () => {
2445 | expect(client).toBeInstanceOf(ReflagClient);
2446 | });
2447 |
2448 | it("should return a new client instance with merged attributes", () => {
2449 | const userOverride = { sex: "male", age: 30 };
2450 | const companyOverride = { employees: 200, bankrupt: false };
2451 | const otherOverride = { key: "new-value" };
2452 | const other = { key: "value" };
2453 |
2454 | const newClient = client
2455 | .bindClient({
2456 | user,
2457 | company,
2458 | other,
2459 | })
2460 | .bindClient({
2461 | user: { id: user.id, ...userOverride },
2462 | company: { id: company.id, ...companyOverride },
2463 | other: otherOverride,
2464 | });
2465 |
2466 | expect(newClient["_options"]).toEqual({
2467 | user: { ...user, ...userOverride },
2468 | company: { ...company, ...companyOverride },
2469 | other: { ...other, ...otherOverride },
2470 | enableTracking: true,
2471 | });
2472 | });
2473 |
2474 | it("should allow using expected methods when bound to user", async () => {
2475 | const boundClient = client.bindClient({ user });
2476 | expect(boundClient.user).toEqual(user);
2477 |
2478 | expect(
2479 | boundClient.bindClient({ other: otherContext }).otherContext,
2480 | ).toEqual(otherContext);
2481 |
2482 | boundClient.getFlags();
2483 |
2484 | await boundClient.track("flag");
2485 | await client.flush();
2486 |
2487 | expect(httpClient.post).toHaveBeenCalledWith(
2488 | BULK_ENDPOINT,
2489 | expectedHeaders,
2490 | [
2491 | expect.objectContaining({ type: "user" }),
2492 | {
2493 | event: "flag",
2494 | type: "event",
2495 | userId: "user123",
2496 | },
2497 | ],
2498 | );
2499 | });
2500 |
2501 | it("should add company ID from the context if not explicitly supplied", async () => {
2502 | const boundClient = client.bindClient({ user, company });
2503 |
2504 | boundClient.getFlags();
2505 | await boundClient.track("flag");
2506 |
2507 | await client.flush();
2508 |
2509 | expect(httpClient.post).toHaveBeenCalledWith(
2510 | BULK_ENDPOINT,
2511 | expectedHeaders,
2512 | [
2513 | expect.objectContaining({ type: "company" }),
2514 | expect.objectContaining({ type: "user" }),
2515 | {
2516 | companyId: "company123",
2517 | event: "flag",
2518 | type: "event",
2519 | userId: "user123",
2520 | },
2521 | ],
2522 | );
2523 | });
2524 |
2525 | it("should disable tracking within the client if `enableTracking` is `false`", async () => {
2526 | const boundClient = client.bindClient({
2527 | user,
2528 | company,
2529 | enableTracking: false,
2530 | });
2531 |
2532 | const { track } = boundClient.getFlag("flag2");
2533 | await track();
2534 | await boundClient.track("flag1");
2535 |
2536 | await client.flush();
2537 |
2538 | expect(httpClient.post).not.toHaveBeenCalled();
2539 | });
2540 |
2541 | it("should allow using expected methods", async () => {
2542 | const boundClient = client.bindClient({ other: { key: "value" } });
2543 | expect(boundClient.otherContext).toEqual({
2544 | key: "value",
2545 | });
2546 |
2547 | await client.initialize();
2548 |
2549 | boundClient.getFlags();
2550 | boundClient.getFlag("flag1");
2551 |
2552 | await boundClient.flush();
2553 | });
2554 |
2555 | it("should return raw flags for bootstrap from bound client", async () => {
2556 | // Ensure client is properly initialized
2557 | await client.initialize();
2558 | const boundClient = client.bindClient({
2559 | user,
2560 | company,
2561 | other: otherContext,
2562 | enableTracking: true,
2563 | });
2564 |
2565 | const result = boundClient.getFlagsForBootstrap();
2566 |
2567 | expect(result).toStrictEqual({
2568 | context: {
2569 | user,
2570 | company,
2571 | other: otherContext,
2572 | enableTracking: true,
2573 | },
2574 | flags: {
2575 | flag1: {
2576 | key: "flag1",
2577 | isEnabled: true,
2578 | targetingVersion: 1,
2579 | config: {
2580 | key: "config-1",
2581 | payload: {
2582 | something: "else",
2583 | },
2584 | targetingVersion: 1,
2585 | missingContextFields: [],
2586 | ruleEvaluationResults: [true],
2587 | },
2588 | ruleEvaluationResults: [true],
2589 | missingContextFields: [],
2590 | },
2591 | flag2: {
2592 | key: "flag2",
2593 | isEnabled: false,
2594 | targetingVersion: 2,
2595 | config: {
2596 | key: undefined,
2597 | payload: undefined,
2598 | targetingVersion: undefined,
2599 | missingContextFields: [],
2600 | ruleEvaluationResults: [],
2601 | },
2602 | ruleEvaluationResults: [false],
2603 | missingContextFields: ["attributeKey"],
2604 | },
2605 | },
2606 | });
2607 |
2608 | // Should not have track function like regular getFlags
2609 | expect(result.flags.flag1).not.toHaveProperty("track");
2610 | expect(result.flags.flag2).not.toHaveProperty("track");
2611 | });
2612 |
2613 | describe("getFlagRemote/getFlagsRemote", () => {
2614 | beforeEach(async () => {
2615 | httpClient.get.mockClear();
2616 | httpClient.get.mockResolvedValue({
2617 | ok: true,
2618 | status: 200,
2619 | body: {
2620 | success: true,
2621 | remoteContextUsed: true,
2622 | features: {
2623 | flag1: {
2624 | key: "flag1",
2625 | targetingVersion: 1,
2626 | isEnabled: true,
2627 | config: {
2628 | key: "config-1",
2629 | version: 3,
2630 | default: true,
2631 | payload: { something: "else" },
2632 | missingContextFields: ["else"],
2633 | },
2634 | },
2635 | flag2: {
2636 | key: "flag2",
2637 | targetingVersion: 2,
2638 | isEnabled: false,
2639 | missingContextFields: ["something"],
2640 | },
2641 | },
2642 | },
2643 | });
2644 | });
2645 |
2646 | it("should return evaluated flags", async () => {
2647 | const boundClient = client.bindClient({
2648 | user,
2649 | company,
2650 | other: otherContext,
2651 | });
2652 |
2653 | const result = await boundClient.getFlagsRemote();
2654 |
2655 | expect(result).toStrictEqual({
2656 | flag1: {
2657 | key: "flag1",
2658 | isEnabled: true,
2659 | config: { key: "config-1", payload: { something: "else" } },
2660 | track: expect.any(Function),
2661 | },
2662 | flag2: {
2663 | key: "flag2",
2664 | isEnabled: false,
2665 | config: { key: undefined, payload: undefined },
2666 | track: expect.any(Function),
2667 | },
2668 | });
2669 |
2670 | expect(httpClient.get).toHaveBeenCalledTimes(1);
2671 |
2672 | expect(httpClient.get).toHaveBeenCalledWith(
2673 | "https://api.example.com/features/evaluated?context.user.id=user123&context.user.age=1&context.user.name=John&context.company.id=company123&context.company.employees=100&context.company.name=Acme+Inc.&context.other.custom=context&context.other.key=value",
2674 | expectedHeaders,
2675 | API_TIMEOUT_MS,
2676 | );
2677 | });
2678 |
2679 | it("should return evaluated flag", async () => {
2680 | const boundClient = client.bindClient({
2681 | user,
2682 | company,
2683 | other: otherContext,
2684 | });
2685 |
2686 | const result = await boundClient.getFlagRemote("flag1");
2687 |
2688 | expect(result).toStrictEqual({
2689 | key: "flag1",
2690 | isEnabled: true,
2691 | config: { key: "config-1", payload: { something: "else" } },
2692 | track: expect.any(Function),
2693 | });
2694 |
2695 | expect(httpClient.get).toHaveBeenCalledTimes(1);
2696 |
2697 | expect(httpClient.get).toHaveBeenCalledWith(
2698 | "https://api.example.com/features/evaluated?context.user.id=user123&context.user.age=1&context.user.name=John&context.company.id=company123&context.company.employees=100&context.company.name=Acme+Inc.&context.other.custom=context&context.other.key=value&key=flag1",
2699 | expectedHeaders,
2700 | API_TIMEOUT_MS,
2701 | );
2702 | });
2703 | });
2704 | });
2705 |
```