This is page 6 of 7. Use http://codebase.md/bucketco/bucket-javascript-sdk?lines=false&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
import fs from "fs";
import {
EvaluationResult,
flattenJSON,
newEvaluator,
} from "@reflag/flag-evaluation";
import BatchBuffer from "./batch-buffer";
import {
API_BASE_URL,
API_TIMEOUT_MS,
FLAG_EVENT_RATE_LIMITER_WINDOW_SIZE_MS,
FLAGS_REFETCH_MS,
loadConfig,
REFLAG_LOG_PREFIX,
SDK_VERSION,
SDK_VERSION_HEADER_NAME,
} from "./config";
import fetchClient, { withRetry } from "./fetch-http-client";
import { subscribe as triggerOnExit } from "./flusher";
import inRequestCache from "./inRequestCache";
import periodicallyUpdatingCache from "./periodicallyUpdatingCache";
import { newRateLimiter } from "./rate-limiter";
import type {
BootstrappedFlags,
CachedFlagDefinition,
CacheStrategy,
EvaluatedFlagsAPIResponse,
FlagDefinition,
FlagOverrides,
FlagOverridesFn,
IdType,
RawFlag,
RawFlags,
TypedFlagKey,
} from "./types";
import {
Attributes,
Cache,
ClientOptions,
Context,
ContextWithTracking,
FlagEvent,
FlagsAPIResponse,
HttpClient,
Logger,
TrackingMeta,
TrackOptions,
TypedFlags,
} from "./types";
import {
applyLogLevel,
decorateLogger,
hashObject,
idOk,
isObject,
mergeSkipUndefined,
ok,
once,
} from "./utils";
const reflagConfigDefaultFile = "reflag.config.json";
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
type BulkEvent =
| {
type: "company";
companyId: IdType;
userId?: IdType;
attributes?: Attributes;
context?: TrackingMeta;
}
| {
type: "user";
userId: IdType;
attributes?: Attributes;
context?: TrackingMeta;
}
| {
type: "feature-flag-event";
action: "check" | "check-config";
key: string;
targetingVersion?: number;
evalResult:
| boolean
| { key: string; payload: any }
| { key: undefined; payload: undefined };
evalContext?: Record<string, any>;
evalRuleResults?: boolean[];
evalMissingFields?: string[];
}
| {
type: "event";
event: string;
companyId?: IdType;
userId: IdType;
attributes?: Attributes;
context?: TrackingMeta;
};
/**
* The SDK client.
*
* @remarks
* This is the main class for interacting with Reflag.
* It is used to evaluate flags, update user and company contexts, and track events.
*
* @example
* ```ts
* // set the REFLAG_SECRET_KEY environment variable or pass the secret key to the constructor
* const client = new ReflagClient();
*
* // evaluate a flag
* const isFlagEnabled = client.getFlag("flag-key", {
* user: { id: "user-id" },
* company: { id: "company-id" },
* });
* ```
**/
export class ReflagClient {
private _config: {
apiBaseUrl: string;
refetchInterval: number;
staleWarningInterval: number;
headers: Record<string, string>;
fallbackFlags?: RawFlags;
flagOverrides: FlagOverridesFn;
offline: boolean;
configFile?: string;
flagsFetchRetries: number;
fetchTimeoutMs: number;
cacheStrategy: CacheStrategy;
};
httpClient: HttpClient;
private flagsCache: Cache<CachedFlagDefinition[]>;
private batchBuffer: BatchBuffer<BulkEvent>;
private rateLimiter: ReturnType<typeof newRateLimiter>;
/**
* Gets the logger associated with the client.
*/
public readonly logger: Logger;
private initializationFinished = false;
private _initialize = once(async () => {
const start = Date.now();
if (!this._config.offline) {
await this.flagsCache.refresh();
}
this.logger.info(
"Reflag initialized in " +
Math.round(Date.now() - start) +
"ms" +
(this._config.offline ? " (offline mode)" : ""),
);
this.initializationFinished = true;
});
/**
* Creates a new SDK client.
* See README for configuration options.
*
* @param options - The options for the client or an existing client to clone.
* @param options.secretKey - The secret key to use for the client.
* @param options.apiBaseUrl - The base URL to send requests to (optional).
* @param options.logger - The logger to use for logging (optional).
* @param options.httpClient - The HTTP client to use for sending requests (optional).
* @param options.logLevel - The log level to use for logging (optional).
* @param options.offline - Whether to run in offline mode (optional).
* @param options.fallbackFlags - The fallback flags to use if the flag is not found (optional).
* @param options.batchOptions - The options for the batch buffer (optional).
* @param options.flagOverrides - The flag overrides to use for the client (optional).
* @param options.configFile - The path to the config file (optional).
* @param options.flagsFetchRetries - Number of retries for fetching flags (optional, defaults to 3).
* @param options.fetchTimeoutMs - Timeout for fetching flags (optional, defaults to 10000ms).
* @param options.cacheStrategy - The cache strategy to use for the client (optional, defaults to "periodically-update").
*
* @throws An error if the options are invalid.
**/
constructor(options: ClientOptions = {}) {
ok(isObject(options), "options must be an object");
ok(
options.host === undefined ||
(typeof options.host === "string" && options.host.length > 0),
"host must be a string",
);
ok(
options.apiBaseUrl === undefined ||
(typeof options.apiBaseUrl === "string" &&
options.apiBaseUrl.length > 0),
"apiBaseUrl must be a string",
);
ok(
options.logger === undefined || isObject(options.logger),
"logger must be an object",
);
ok(
options.httpClient === undefined || isObject(options.httpClient),
"httpClient must be an object",
);
ok(
options.fallbackFlags === undefined ||
Array.isArray(options.fallbackFlags) ||
isObject(options.fallbackFlags),
"fallbackFlags must be an array or object",
);
ok(
options.batchOptions === undefined || isObject(options.batchOptions),
"batchOptions must be an object",
);
ok(
options.configFile === undefined ||
typeof options.configFile === "string",
"configFile must be a string",
);
ok(
options.flagsFetchRetries === undefined ||
(Number.isInteger(options.flagsFetchRetries) &&
options.flagsFetchRetries >= 0),
"flagsFetchRetries must be a non-negative integer",
);
ok(
options.fetchTimeoutMs === undefined ||
(Number.isInteger(options.fetchTimeoutMs) &&
options.fetchTimeoutMs >= 0),
"fetchTimeoutMs must be a non-negative integer",
);
if (!options.configFile) {
options.configFile =
(process.env.REFLAG_CONFIG_FILE ??
fs.existsSync(reflagConfigDefaultFile))
? reflagConfigDefaultFile
: undefined;
}
const externalConfig = loadConfig(options.configFile);
const config = mergeSkipUndefined(externalConfig, options);
const offline = config.offline ?? process.env.NODE_ENV === "test";
if (!offline) {
ok(
typeof config.secretKey === "string",
"secretKey must be a string, or set offline=true",
);
ok(config.secretKey.length > 22, "invalid secretKey specified");
}
// use the supplied logger or apply the log level to the console logger
const logLevel = options.logLevel ?? config?.logLevel ?? "INFO";
this.logger = options.logger
? options.logger
: applyLogLevel(decorateLogger(REFLAG_LOG_PREFIX, console), logLevel);
const fallbackFlags = Array.isArray(options.fallbackFlags)
? options.fallbackFlags.reduce((acc, key) => {
acc[key as TypedFlagKey] = {
isEnabled: true,
key,
};
return acc;
}, {} as RawFlags)
: isObject(options.fallbackFlags)
? Object.entries(options.fallbackFlags).reduce(
(acc, [key, fallback]) => {
acc[key as TypedFlagKey] = {
isEnabled:
typeof fallback === "object"
? fallback.isEnabled
: !!fallback,
key,
config:
typeof fallback === "object" && fallback.config
? {
key: fallback.config.key,
payload: fallback.config.payload,
}
: undefined,
};
return acc;
},
{} as RawFlags,
)
: undefined;
this.rateLimiter = newRateLimiter(FLAG_EVENT_RATE_LIMITER_WINDOW_SIZE_MS);
this.httpClient = options.httpClient || fetchClient;
this.batchBuffer = new BatchBuffer<BulkEvent>({
...options?.batchOptions,
flushHandler: (items) => this.sendBulkEvents(items),
logger: this.logger,
});
this._config = {
offline,
apiBaseUrl: (config.apiBaseUrl ?? config.host) || API_BASE_URL,
headers: {
"Content-Type": "application/json",
[SDK_VERSION_HEADER_NAME]: SDK_VERSION,
["Authorization"]: `Bearer ${config.secretKey}`,
},
refetchInterval: FLAGS_REFETCH_MS,
staleWarningInterval: FLAGS_REFETCH_MS * 5,
fallbackFlags: fallbackFlags,
flagOverrides:
typeof config.flagOverrides === "function"
? config.flagOverrides
: () => config.flagOverrides,
flagsFetchRetries: options.flagsFetchRetries ?? 3,
fetchTimeoutMs: options.fetchTimeoutMs ?? API_TIMEOUT_MS,
cacheStrategy: options.cacheStrategy ?? "periodically-update",
};
if ((config.batchOptions?.flushOnExit ?? true) && !this._config.offline) {
triggerOnExit(() => this.flush());
}
if (!new URL(this._config.apiBaseUrl).pathname.endsWith("/")) {
this._config.apiBaseUrl += "/";
}
const fetchFlags = async () => {
const res = await this.get<FlagsAPIResponse>(
"features",
this._config.flagsFetchRetries,
);
if (!isObject(res) || !Array.isArray(res?.features)) {
this.logger.warn("flags cache: invalid response", res);
return undefined;
}
return res.features.map((flagDef) => {
return {
...flagDef,
enabledEvaluator: newEvaluator(
flagDef.targeting.rules.map((rule) => ({
filter: rule.filter,
value: true,
})),
),
configEvaluator: flagDef.config
? newEvaluator(
flagDef.config?.variants.map((variant) => ({
filter: variant.filter,
value: {
key: variant.key,
payload: variant.payload,
},
})),
)
: undefined,
} satisfies CachedFlagDefinition;
});
};
if (this._config.cacheStrategy === "periodically-update") {
this.flagsCache = periodicallyUpdatingCache<CachedFlagDefinition[]>(
this._config.refetchInterval,
this._config.staleWarningInterval,
this.logger,
fetchFlags,
);
} else {
this.flagsCache = inRequestCache<CachedFlagDefinition[]>(
this._config.refetchInterval,
this.logger,
fetchFlags,
);
}
}
/**
* Sets the flag overrides.
*
* @param overrides - The flag overrides.
*
* @remarks
* The flag overrides are used to override the flag definitions.
* This is useful for testing or development.
*
* @example
* ```ts
* client.flagOverrides = {
* "flag-1": true,
* "flag-2": false,
* };
* ```
**/
set flagOverrides(overrides: FlagOverridesFn | FlagOverrides) {
if (typeof overrides === "object") {
this._config.flagOverrides = () => overrides;
} else {
this._config.flagOverrides = overrides;
}
}
/**
* Clears the flag overrides.
*
* @remarks
* This is useful for testing or development.
*
* @example
* ```ts
* afterAll(() => {
* client.clearFlagOverrides();
* });
* ```
**/
clearFlagOverrides() {
this._config.flagOverrides = () => ({});
}
/**
* Returns a new BoundReflagClient with the user/company/otherContext
* set to be used in subsequent calls.
* For example, for evaluating flag targeting or tracking events.
*
* @param context - The context to bind the client to.
* @param context.enableTracking - Whether to enable tracking for the context.
* @param context.user - The user context.
* @param context.company - The company context.
* @param context.other - The other context.
*
* @returns A new client bound with the arguments given.
*
* @throws An error if the user/company is given but their ID is not a string.
*
* @remarks
* The `updateUser` / `updateCompany` methods will automatically be called when
* the user/company is set respectively.
**/
public bindClient({
enableTracking = true,
...context
}: ContextWithTracking) {
return new BoundReflagClient(this, { enableTracking, ...context });
}
/**
* Updates the associated user in Reflag.
*
* @param userId - The userId of the user to update.
* @param options - The options for the user.
* @param options.attributes - The additional attributes of the user (optional).
* @param options.meta - The meta context associated with tracking (optional).
*
* @throws An error if the company is not set or the options are invalid.
* @remarks
* The company must be set using `withCompany` before calling this method.
* If the user is set, the company will be associated with the user.
**/
public async updateUser(userId: IdType, options?: TrackOptions) {
idOk(userId, "userId");
ok(options === undefined || isObject(options), "options must be an object");
ok(
options?.attributes === undefined || isObject(options.attributes),
"attributes must be an object",
);
checkMeta(options?.meta);
if (this._config.offline) {
return;
}
if (this.rateLimiter.isAllowed(hashObject({ ...options, userId }))) {
await this.batchBuffer.add({
type: "user",
userId,
attributes: options?.attributes,
context: options?.meta,
});
}
}
/**
* Updates the associated company in Reflag.
*
* @param companyId - The companyId of the company to update.
* @param options - The options for the company.
* @param options.attributes - The additional attributes of the company (optional).
* @param options.meta - The meta context associated with tracking (optional).
* @param options.userId - The userId of the user to associate with the company (optional).
*
* @throws An error if the company is not set or the options are invalid.
* @remarks
* The company must be set using `withCompany` before calling this method.
* If the user is set, the company will be associated with the user.
**/
public async updateCompany(
companyId: IdType,
options?: TrackOptions & { userId?: IdType },
) {
idOk(companyId, "companyId");
ok(options === undefined || isObject(options), "options must be an object");
ok(
options?.attributes === undefined || isObject(options.attributes),
"attributes must be an object",
);
checkMeta(options?.meta);
if (typeof options?.userId !== "undefined") {
idOk(options?.userId, "userId");
}
if (this._config.offline) {
return;
}
if (this.rateLimiter.isAllowed(hashObject({ ...options, companyId }))) {
await this.batchBuffer.add({
type: "company",
companyId,
userId: options?.userId,
attributes: options?.attributes,
context: options?.meta,
});
}
}
/**
* Tracks an event in Reflag.
* @param options.companyId - Optional company ID for the event (optional).
*
* @throws An error if the user is not set or the event is invalid or the options are invalid.
* @remarks
* If the company is set, the event will be associated with the company.
**/
public async track(
userId: IdType,
event: string,
options?: TrackOptions & { companyId?: IdType },
) {
idOk(userId, "userId");
ok(typeof event === "string" && event.length > 0, "event must be a string");
ok(options === undefined || isObject(options), "options must be an object");
ok(
options?.attributes === undefined || isObject(options.attributes),
"attributes must be an object",
);
ok(
options?.meta === undefined || isObject(options.meta),
"meta must be an object",
);
if (options?.companyId !== undefined) {
idOk(options?.companyId, "companyId");
}
if (this._config.offline) {
return;
}
await this.batchBuffer.add({
type: "event",
event,
companyId: options?.companyId,
userId,
attributes: options?.attributes,
context: options?.meta,
});
}
/**
* Initializes the client by caching the flags definitions.
*
* @remarks
* Call this method before calling `getFlags` to ensure the flag definitions are cached.
* The client will ignore subsequent calls to this method.
**/
public async initialize() {
await this._initialize();
return;
}
/**
* Flushes and completes any in-flight fetches in the flag cache.
*
* @remarks
* It is recommended to call this method when the application is shutting down to ensure all events are sent
* before the process exits.
*
* This method is automatically called when the process exits if `batchOptions.flushOnExit` is `true` in the options (default).
*/
public async flush() {
if (this._config.offline) {
return;
}
await this.batchBuffer.flush();
await this.flagsCache.waitRefresh();
}
/**
* Destroys the client and cleans up all resources including timers and background processes.
*
* @remarks
* After calling this method, the client should not be used anymore.
* This is particularly useful in development environments with hot reloading to prevent
* multiple background processes from running simultaneously.
*/
public destroy() {
this.flagsCache.destroy();
this.batchBuffer.destroy();
}
/**
* Gets the flag definitions, including all config values.
* To evaluate which flags are enabled for a given user/company, use `getFlags`.
*
* @returns The flags definitions.
*/
public getFlagDefinitions(): FlagDefinition[] {
const flags = this.flagsCache.get() || [];
return flags.map((f) => ({
key: f.key,
description: f.description,
flag: f.targeting,
config: f.config,
}));
}
/**
* Gets the evaluated flags for the current context which includes the user, company, and custom context.
*
* @param options - The options for the context.
* @param options.enableTracking - Whether to enable tracking for the context.
* @param options.meta - The meta context associated with the context.
* @param options.user - The user context.
* @param options.company - The company context.
* @param options.other - The other context.
*
* @returns The evaluated flags.
*
* @remarks
* Call `initialize` before calling this method to ensure the flag definitions are cached, no flags will be returned otherwise.
**/
public getFlags({
enableTracking = true,
...context
}: ContextWithTracking): TypedFlags {
const contextWithTracking = { enableTracking, ...context };
const rawFlags = this._getFlags(contextWithTracking);
return Object.fromEntries(
Object.entries(rawFlags).map(([key, rawFlag]) => [
key,
this._wrapRawFlag(contextWithTracking, rawFlag),
]),
);
}
/**
* Gets the evaluated flag for the current context which includes the user, company, and custom context.
* Using the `isEnabled` property sends a `check` event to Reflag.
*
* @param key - The key of the flag to get.
* @returns The evaluated flag.
*
* @remarks
* Call `initialize` before calling this method to ensure the flag definitions are cached, no flags will be returned otherwise.
**/
public getFlag<TKey extends TypedFlagKey>(
{ enableTracking = true, ...context }: ContextWithTracking,
key: TKey,
): TypedFlags[TKey] {
const contextWithTracking = { enableTracking, ...context };
const rawFlag = this._getFlags(contextWithTracking, key);
return this._wrapRawFlag(
{ enableChecks: true, ...contextWithTracking },
rawFlag ?? { key },
);
}
/**
* Gets the evaluated flags for the current context without wrapping them in getters.
* This method returns raw flag data suitable for bootstrapping client-side applications.
*
* @param options - The options for the context.
* @param options.enableTracking - Whether to enable tracking for the context.
* @param options.meta - The meta context associated with the context.
* @param options.user - The user context.
* @param options.company - The company context.
* @param options.other - The other context.
*
* @returns The evaluated raw flags and the context.
*
* @remarks
* Call `initialize` before calling this method to ensure the flag definitions are cached, no flags will be returned otherwise.
* This method returns RawFlag objects without wrapping them in getters, making them suitable for serialization.
**/
public getFlagsForBootstrap({
enableTracking = true,
...context
}: ContextWithTracking): BootstrappedFlags {
const contextWithTracking = { enableTracking, ...context };
return {
context: contextWithTracking,
flags: this._getFlags(contextWithTracking),
};
}
/**
* Gets evaluated flags with the usage of remote context.
* This method triggers a network request every time it's called.
*
* @param userId - The userId of the user to get the flags for.
* @param companyId - The companyId of the company to get the flags for.
* @param additionalContext - The additional context to get the flags for.
*
* @returns evaluated flags
*/
public async getFlagsRemote(
userId?: IdType,
companyId?: IdType,
additionalContext?: Context,
): Promise<TypedFlags> {
return this._getFlagsRemote(
undefined,
userId,
companyId,
additionalContext,
);
}
/**
* Gets evaluated flag with the usage of remote context.
* This method triggers a network request every time it's called.
*
* @param key - The key of the flag to get.
* @param userId - The userId of the user to get the flag for.
* @param companyId - The companyId of the company to get the flag for.
* @param additionalContext - The additional context to get the flag for.
*
* @returns evaluated flag
*/
public async getFlagRemote<TKey extends TypedFlagKey>(
key: TKey,
userId?: IdType,
companyId?: IdType,
additionalContext?: Context,
): Promise<TypedFlags[TKey]> {
return this._getFlagsRemote(key, userId, companyId, additionalContext);
}
private buildUrl(path: string) {
if (path.startsWith("/")) {
path = path.slice(1);
}
const url = new URL(path, this._config.apiBaseUrl);
return url.toString();
}
/**
* Sends a POST request to the specified path.
*
* @param path - The path to send the request to.
* @param body - The body of the request.
*
* @returns A boolean indicating if the request was successful.
*
* @throws An error if the path or body is invalid.
**/
private async post<TBody>(path: string, body: TBody) {
ok(typeof path === "string" && path.length > 0, "path must be a string");
ok(typeof body === "object", "body must be an object");
const url = this.buildUrl(path);
try {
const response = await this.httpClient.post<TBody, { success: boolean }>(
url,
this._config.headers,
body,
);
this.logger.debug(`post request to "${url}"`, response);
if (!response.ok || !isObject(response.body) || !response.body.success) {
this.logger.warn(
`invalid response received from server for "${url}"`,
JSON.stringify(response),
);
return false;
}
return true;
} catch (error) {
this.logger.error(`post request to "${url}" failed with error`, error);
return false;
}
}
/**
* Sends a GET request to the specified path.
*
* @param path - The path to send the request to.
* @param retries - Optional number of retries for the request.
*
* @returns The response from the server.
* @throws An error if the path is invalid.
**/
private async get<TResponse>(path: string, retries: number = 3) {
ok(typeof path === "string" && path.length > 0, "path must be a string");
try {
const url = this.buildUrl(path);
return await withRetry(
async () => {
const response = await this.httpClient.get<
TResponse & { success: boolean }
>(url, this._config.headers, this._config.fetchTimeoutMs);
this.logger.debug(`get request to "${url}"`, response);
if (
!response.ok ||
!isObject(response.body) ||
!response.body.success
) {
throw new Error(
`invalid response received from server for "${url}": ${JSON.stringify(response.body)}`,
);
}
const { success: _, ...result } = response.body;
return result as TResponse;
},
() => {
this.logger.warn("failed to fetch flags, will retry");
},
retries,
1000,
10000,
);
} catch (error) {
this.logger.error(
`get request to "${path}" failed with error after ${retries} retries`,
error,
);
return undefined;
}
}
/**
* Sends a batch of events to the Reflag API.
*
* @param events - The events to send.
*
* @throws An error if the send fails.
**/
private async sendBulkEvents(events: BulkEvent[]) {
ok(
Array.isArray(events) && events.length > 0,
"events must be a non-empty array",
);
const sent = await this.post("bulk", events);
if (!sent) {
throw new Error("Failed to send bulk events");
}
}
/**
* Sends a flag event to the Reflag API.
*
* Flag events are used to track the evaluation of flag targeting rules.
* "check" events are sent when a flag's `isEnabled` property is checked.
* "evaluate" events are sent when a flag's targeting rules are matched against
* the current context.
*
* @param event - The event to send.
* @param event.action - The action to send.
* @param event.key - The key of the flag to send.
* @param event.targetingVersion - The targeting version of the flag to send.
* @param event.evalResult - The evaluation result of the flag to send.
* @param event.evalContext - The evaluation context of the flag to send.
* @param event.evalRuleResults - The evaluation rule results of the flag to send.
* @param event.evalMissingFields - The evaluation missing fields of the flag to send.
*
* @throws An error if the event is invalid.
*
* @remarks
* This method is rate-limited to prevent too many events from being sent.
**/
private async sendFlagEvent(event: FlagEvent) {
ok(typeof event === "object", "event must be an object");
ok(
typeof event.action === "string" &&
(event.action === "check" || event.action === "check-config"),
"event must have an action",
);
ok(
typeof event.key === "string" && event.key.length > 0,
"event must have a flag key",
);
ok(
typeof event.targetingVersion === "number" ||
event.targetingVersion === undefined,
"event must have a targeting version",
);
ok(
typeof event.evalResult === "boolean" || isObject(event.evalResult),
"event must have an evaluation result",
);
ok(
event.evalContext === undefined || typeof event.evalContext === "object",
"event context must be an object",
);
ok(
event.evalRuleResults === undefined ||
Array.isArray(event.evalRuleResults),
"event rule results must be an array",
);
ok(
event.evalMissingFields === undefined ||
Array.isArray(event.evalMissingFields),
"event missing fields must be an array",
);
const contextKey = new URLSearchParams(
flattenJSON(event.evalContext || {}),
).toString();
if (this._config.offline) {
return;
}
if (
!this.rateLimiter.isAllowed(
hashObject({
action: event.action,
key: event.key,
targetingVersion: event.targetingVersion,
evalResult: event.evalResult,
contextKey,
}),
)
) {
return;
}
await this.batchBuffer.add({
type: "feature-flag-event",
action: event.action,
key: event.key,
targetingVersion: event.targetingVersion,
evalContext: event.evalContext,
evalResult: event.evalResult,
evalRuleResults: event.evalRuleResults,
evalMissingFields: event.evalMissingFields,
});
}
/**
* Updates the context in Reflag (if needed).
* This method should be used before requesting flags or binding a client.
*
* @param options - The options for the context.
* @param options.enableTracking - Whether to enable tracking for the context.
* @param options.meta - The meta context associated with the context.
* @param options.user - The user context.
* @param options.company - The company context.
* @param options.other - The other context.
*/
private async syncContext(options: ContextWithTracking) {
if (!options.enableTracking) {
this.logger.debug("tracking disabled, not updating user/company");
return;
}
const promises: Promise<void>[] = [];
if (typeof options.company?.id !== "undefined") {
const { id: _, ...attributes } = options.company;
promises.push(
this.updateCompany(options.company.id, {
attributes,
meta: options.meta,
}),
);
}
if (typeof options.user?.id !== "undefined") {
const { id: _, ...attributes } = options.user;
promises.push(
this.updateUser(options.user.id, {
attributes,
meta: options.meta,
}),
);
}
if (promises.length > 0) {
await Promise.all(promises);
}
}
/**
* Warns if a flag has targeting rules that require context fields that are missing.
*
* @param context - The context.
* @param flag - The flag to check.
*/
private _warnMissingFlagContextFields(
context: Context,
flag: {
key: string;
missingContextFields?: string[];
config?: {
key: string;
missingContextFields?: string[];
};
},
) {
const report: Record<string, string[]> = {};
const { config, ...flagData } = flag;
if (
flagData.missingContextFields?.length &&
this.rateLimiter.isAllowed(
hashObject({
flagKey: flagData.key,
missingContextFields: flagData.missingContextFields,
context,
}),
)
) {
report[flagData.key] = flagData.missingContextFields;
}
if (
config?.missingContextFields?.length &&
this.rateLimiter.isAllowed(
hashObject({
flagKey: flagData.key,
configKey: config.key,
missingContextFields: config.missingContextFields,
context,
}),
)
) {
report[`${flagData.key}.config`] = config.missingContextFields;
}
if (Object.keys(report).length > 0) {
this.logger.warn(
`flag targeting rules might not be correctly evaluated due to missing context fields.`,
report,
);
}
}
private _getFlags(options: ContextWithTracking): RawFlags;
private _getFlags<TKey extends TypedFlagKey>(
options: ContextWithTracking,
key: TKey,
): RawFlag | undefined;
private _getFlags<TKey extends TypedFlagKey>(
options: ContextWithTracking,
key?: TKey,
): RawFlags | RawFlag | undefined {
checkContextWithTracking(options);
if (!this.initializationFinished) {
this.logger.error("getFlag(s): ReflagClient is not initialized yet.");
}
void this.syncContext(options);
let flagDefinitions: CachedFlagDefinition[] = [];
if (!this._config.offline) {
const flagDefs = this.flagsCache.get();
if (!flagDefs) {
this.logger.warn(
"no flag definitions available, using fallback flags.",
);
const fallbackFlags = this._config.fallbackFlags || {};
if (key) {
return fallbackFlags[key];
}
return fallbackFlags;
}
flagDefinitions = flagDefs;
}
const { enableTracking: _, meta: __, ...context } = options;
const evaluated = flagDefinitions
.filter(({ key: flagKey }) => (key ? key === flagKey : true))
.map((flag) => ({
flagKey: flag.key,
targetingVersion: flag.targeting.version,
configVersion: flag.config?.version,
enabledResult: flag.enabledEvaluator(context, flag.key),
configResult:
flag.configEvaluator?.(context, flag.key) ??
({
flagKey: flag.key,
context,
value: undefined,
ruleEvaluationResults: [],
missingContextFields: [],
} satisfies EvaluationResult<any>),
}));
let evaluatedFlags = evaluated.reduce((acc, res) => {
acc[res.flagKey as TypedFlagKey] = {
key: res.flagKey,
isEnabled: res.enabledResult.value ?? false,
ruleEvaluationResults: res.enabledResult.ruleEvaluationResults,
missingContextFields: res.enabledResult.missingContextFields,
targetingVersion: res.targetingVersion,
config: {
key: res.configResult?.value?.key,
payload: res.configResult?.value?.payload,
targetingVersion: res.configVersion,
ruleEvaluationResults: res.configResult?.ruleEvaluationResults,
missingContextFields: res.configResult?.missingContextFields,
},
};
return acc;
}, {} as RawFlags);
const overrides = Object.entries(this._config.flagOverrides(context))
.filter(([flagKey]) => (key ? key === flagKey : true))
.map(([flagKey, override]) => [
flagKey,
isObject(override)
? {
key: flagKey,
isEnabled: override.isEnabled,
config: override.config,
}
: {
key: flagKey,
isEnabled: !!override,
config: undefined,
},
]);
if (overrides.length > 0) {
// merge overrides into evaluated flags
evaluatedFlags = {
...evaluatedFlags,
...Object.fromEntries(overrides),
};
}
if (key) {
return evaluatedFlags[key];
}
return evaluatedFlags;
}
private _wrapRawFlag<TKey extends TypedFlagKey>(
{
enableTracking,
enableChecks = false,
...context
}: { enableTracking: boolean; enableChecks?: boolean } & Context,
{ config, ...flag }: PartialBy<RawFlag, "isEnabled">,
): TypedFlags[TKey] {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const client = this;
const simplifiedConfig = config
? { key: config.key, payload: config.payload }
: { key: undefined, payload: undefined };
return {
get isEnabled() {
if (enableTracking && enableChecks) {
client._warnMissingFlagContextFields(context, flag);
void client
.sendFlagEvent({
action: "check",
key: flag.key,
targetingVersion: flag.targetingVersion,
evalResult: flag.isEnabled ?? false,
evalContext: context,
evalRuleResults: flag.ruleEvaluationResults,
evalMissingFields: flag.missingContextFields,
})
.catch((err) => {
client.logger?.error(
`failed to send check event for "${flag.key}": ${err}`,
err,
);
});
}
return flag.isEnabled ?? false;
},
get config() {
if (enableTracking && enableChecks) {
client._warnMissingFlagContextFields(context, flag);
void client
.sendFlagEvent({
action: "check-config",
key: flag.key,
targetingVersion: config?.targetingVersion,
evalResult: simplifiedConfig,
evalContext: context,
evalRuleResults: config?.ruleEvaluationResults,
evalMissingFields: config?.missingContextFields,
})
.catch((err) => {
client.logger?.error(
`failed to send check event for "${flag.key}": ${err}`,
err,
);
});
}
return simplifiedConfig as TypedFlags[TKey]["config"];
},
key: flag.key,
track: async () => {
if (typeof context.user?.id === "undefined") {
this.logger.warn("no user set, cannot track event");
return;
}
if (enableTracking) {
await this.track(context.user.id, flag.key, {
companyId: context.company?.id,
});
} else {
this.logger.debug("tracking disabled, not tracking event");
}
},
};
}
private async _getFlagsRemote(
key: undefined,
userId?: IdType,
companyId?: IdType,
additionalContext?: Context,
): Promise<TypedFlags>;
private async _getFlagsRemote<TKey extends TypedFlagKey>(
key: TKey,
userId?: IdType,
companyId?: IdType,
additionalContext?: Context,
): Promise<TypedFlags[TKey]>;
private async _getFlagsRemote<TKey extends TypedFlagKey>(
key?: string,
userId?: IdType,
companyId?: IdType,
additionalContext?: Context,
): Promise<TypedFlags | TypedFlags[TKey]> {
const context = additionalContext || {};
if (userId) {
context.user = { id: userId };
}
if (companyId) {
context.company = { id: companyId };
}
const contextWithTracking = {
...context,
enableTracking: true,
};
checkContextWithTracking(contextWithTracking);
const params = new URLSearchParams(
Object.keys(context).length ? flattenJSON({ context }) : undefined,
);
if (key) {
params.append("key", key);
}
const res = await this.get<EvaluatedFlagsAPIResponse>(
`features/evaluated?${params}`,
);
if (key) {
const flag = res?.features[key];
if (!flag) {
this.logger.error(`flag ${key} not found`);
}
return this._wrapRawFlag(contextWithTracking, { key, ...flag });
} else {
return res?.features
? Object.fromEntries(
Object.entries(res?.features).map(([flagKey, flag]) => [
flagKey,
this._wrapRawFlag(contextWithTracking, flag),
]),
)
: {};
}
}
}
/**
* A client bound with a specific user, company, and other context.
*/
export class BoundReflagClient {
private readonly _client: ReflagClient;
private readonly _options: ContextWithTracking;
/**
* (Internal) Creates a new BoundReflagClient. Use `bindClient` to create a new client bound with a specific context.
*
* @param client - The `ReflagClient` to use.
* @param options - The options for the client.
* @param options.enableTracking - Whether to enable tracking for the client.
*
* @internal
*/
constructor(
client: ReflagClient,
{ enableTracking = true, ...context }: ContextWithTracking,
) {
this._client = client;
this._options = { enableTracking, ...context };
checkContextWithTracking(this._options);
void this._client["syncContext"](this._options);
}
/**
* Gets the "other" context associated with the client.
*
* @returns The "other" context or `undefined` if it is not set.
**/
public get otherContext() {
return this._options.other;
}
/**
* Gets the user associated with the client.
*
* @returns The user or `undefined` if it is not set.
**/
public get user() {
return this._options.user;
}
/**
* Gets the company associated with the client.
*
* @returns The company or `undefined` if it is not set.
**/
public get company() {
return this._options.company;
}
/**
* Get flags for the user/company/other context bound to this client.
* Meant for use in serialization of flags for transferring to the client-side/browser.
*
* @returns Flags for the given user/company and whether each one is enabled or not
*/
public getFlags(): TypedFlags {
return this._client.getFlags(this._options);
}
/**
* Get raw flags for the user/company/other context bound to this client without wrapping them in getters.
* This method returns raw flag data suitable for bootstrapping client-side applications.
*
* @returns Raw flags for the given user/company and whether each one is enabled or not
*/
public getFlagsForBootstrap(): BootstrappedFlags {
return this._client.getFlagsForBootstrap({ ...this._options });
}
/**
* Get a specific flag for the user/company/other context bound to this client.
* Using the `isEnabled` property sends a `check` event to Reflag.
*
* @param key - The key of the flag to get.
*
* @returns Flags for the given user/company and whether each one is enabled or not
*/
public getFlag<TKey extends TypedFlagKey>(key: TKey): TypedFlags[TKey] {
return this._client.getFlag(this._options, key);
}
/**
* Get remotely evaluated flag for the user/company/other context bound to this client.
*
* @returns Flags for the given user/company and whether each one is enabled or not
*/
public async getFlagsRemote() {
const { enableTracking: _, meta: __, ...context } = this._options;
return await this._client.getFlagsRemote(undefined, undefined, context);
}
/**
* Get remotely evaluated flag for the user/company/other context bound to this client.
*
* @param key - The key of the flag to get.
*
* @returns Flag for the given user/company and key and whether it's enabled or not
*/
public async getFlagRemote(key: string) {
const { enableTracking: _, meta: __, ...context } = this._options;
return await this._client.getFlagRemote(key, undefined, undefined, context);
}
/**
* Track an event in Reflag.
*
* @param event - The event to track.
* @param options - The options for the event.
* @param options.attributes - The attributes of the event (optional).
* @param options.meta - The meta context associated with tracking (optional).
* @param options.companyId - Optional company ID for the event (optional).
*
* @throws An error if the event is invalid or the options are invalid.
*/
public async track(
event: string,
options?: TrackOptions & { companyId?: string },
) {
ok(options === undefined || isObject(options), "options must be an object");
checkMeta(options?.meta);
const userId = this._options.user?.id;
if (!userId) {
this._client.logger?.warn("no user set, cannot track event");
return;
}
if (!this._options.enableTracking) {
this._client.logger?.debug(
"tracking disabled for this bound client, not tracking event",
);
return;
}
await this._client.track(
userId,
event,
options?.companyId
? options
: { ...options, companyId: this._options.company?.id },
);
}
/**
* Create a new client bound with the additional context.
* Note: This performs a shallow merge for user/company/other individually.
*
* @param context - The context to bind the client to.
* @param context.user - The user to bind the client to.
* @param context.company - The company to bind the client to.
* @param context.other - The other context to bind the client to.
* @param context.enableTracking - Whether to enable tracking for the client.
* @param context.meta - The meta context to bind the client to.
*
* @returns new client bound with the additional context
*/
public bindClient({
user,
company,
other,
enableTracking,
meta,
}: ContextWithTracking) {
// merge new context into existing
const boundConfig = {
...this._options,
user: user ? { ...this._options.user, ...user } : undefined,
company: company ? { ...this._options.company, ...company } : undefined,
other: { ...this._options.other, ...other },
enableTracking: enableTracking ?? this._options.enableTracking,
meta: meta ?? this._options.meta,
};
return new BoundReflagClient(this._client, boundConfig);
}
/**
* Flushes the batch buffer.
*/
public async flush() {
await this._client.flush();
}
}
function checkMeta(
meta?: TrackingMeta,
): asserts meta is TrackingMeta | undefined {
ok(
typeof meta === "undefined" || isObject(meta),
"meta must be an object if given",
);
ok(
meta?.active === undefined || typeof meta?.active === "boolean",
"meta.active must be a boolean if given",
);
}
function checkContext(context: Context): asserts context is Context {
ok(isObject(context), "context must be an object");
ok(
typeof context.user === "undefined" || isObject(context.user),
"user must be an object if given",
);
if (typeof context.user?.id !== "undefined") {
idOk(context.user.id, "user.id");
}
ok(
typeof context.company === "undefined" || isObject(context.company),
"company must be an object if given",
);
if (typeof context.company?.id !== "undefined") {
idOk(context.company.id, "company.id");
}
ok(
context.other === undefined || isObject(context.other),
"other must be an object if given",
);
}
function checkContextWithTracking(
context: ContextWithTracking,
): asserts context is ContextWithTracking & { enableTracking: boolean } {
checkContext(context);
ok(
typeof context.enableTracking === "boolean",
"enableTracking must be a boolean",
);
checkMeta(context.meta);
}
```
--------------------------------------------------------------------------------
/packages/node-sdk/test/client.test.ts:
--------------------------------------------------------------------------------
```typescript
import flushPromises from "flush-promises";
import {
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
test,
vi,
} from "vitest";
import { BoundReflagClient, ReflagClient } from "../src";
import {
API_BASE_URL,
API_TIMEOUT_MS,
BATCH_INTERVAL_MS,
BATCH_MAX_SIZE,
FLAG_EVENT_RATE_LIMITER_WINDOW_SIZE_MS,
FLAGS_REFETCH_MS,
SDK_VERSION,
SDK_VERSION_HEADER_NAME,
} from "../src/config";
import fetchClient from "../src/fetch-http-client";
import { subscribe as triggerOnExit } from "../src/flusher";
import { newRateLimiter } from "../src/rate-limiter";
import { ClientOptions, Context, FlagsAPIResponse } from "../src/types";
const BULK_ENDPOINT = "https://api.example.com/bulk";
vi.mock("../src/rate-limiter", async (importOriginal) => {
const original = (await importOriginal()) as any;
return {
...original,
newRateLimiter: vi.fn(original.newRateLimiter),
};
});
vi.mock("../src/flusher", () => ({
subscribe: vi.fn(),
}));
// Mock NextJS headers module for dynamic import
vi.mock("next/headers", () => ({
cookies: vi.fn(),
}));
const user = {
id: "user123",
age: 1,
name: "John",
};
const company = {
id: "company123",
employees: 100,
name: "Acme Inc.",
};
const event = {
event: "flag-event",
attrs: { key: "value" },
};
const otherContext = { custom: "context", key: "value" };
const logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const httpClient = { post: vi.fn(), get: vi.fn() };
const fallbackFlags = ["key"];
const validOptions: ClientOptions = {
secretKey: "validSecretKeyWithMoreThan22Chars",
apiBaseUrl: "https://api.example.com/",
logger,
httpClient,
fallbackFlags,
flagsFetchRetries: 2,
batchOptions: {
maxSize: 99,
intervalMs: 10001,
flushOnExit: false,
},
offline: false,
};
const expectedHeaders = {
[SDK_VERSION_HEADER_NAME]: SDK_VERSION,
"Content-Type": "application/json",
Authorization: `Bearer ${validOptions.secretKey}`,
};
const flagDefinitions: FlagsAPIResponse = {
features: [
{
key: "flag1",
description: "Flag 1",
targeting: {
version: 1,
rules: [
{
filter: {
type: "context" as const,
field: "company.id",
operator: "IS",
values: ["company123"],
},
},
],
},
config: {
version: 1,
variants: [
{
filter: {
type: "context",
field: "company.id",
operator: "IS",
values: ["company123"],
},
key: "config-1",
payload: { something: "else" },
},
],
},
},
{
key: "flag2",
description: "Flag 2",
targeting: {
version: 2,
rules: [
{
filter: {
type: "group" as const,
operator: "and",
filters: [
{
type: "context" as const,
field: "company.id",
operator: "IS",
values: ["company123"],
},
{
partialRolloutThreshold: 0.5,
partialRolloutAttribute: "attributeKey",
type: "rolloutPercentage" as const,
key: "flag2",
},
],
},
},
],
},
},
],
};
describe("ReflagClient", () => {
afterEach(() => {
vi.clearAllMocks();
});
describe("constructor", () => {
it("should initialize with no options", async () => {
const secretKeyEnv = process.env.REFLAG_SECRET_KEY;
process.env.REFLAG_SECRET_KEY = "validSecretKeyWithMoreThan22Chars";
try {
const reflagInstance = new ReflagClient();
expect(reflagInstance).toBeInstanceOf(ReflagClient);
} finally {
process.env.REFLAG_SECRET_KEY = secretKeyEnv;
}
});
it("should accept fallback flags as an array", async () => {
const reflagInstance = new ReflagClient({
secretKey: "validSecretKeyWithMoreThan22Chars",
fallbackFlags: ["flag1", "flag2"],
});
expect(reflagInstance["_config"].fallbackFlags).toEqual({
flag1: {
isEnabled: true,
key: "flag1",
},
flag2: {
isEnabled: true,
key: "flag2",
},
});
});
it("should accept fallback flags as an object", async () => {
const reflagInstance = new ReflagClient({
secretKey: "validSecretKeyWithMoreThan22Chars",
fallbackFlags: {
flag1: true,
flag2: {
isEnabled: true,
config: {
key: "config1",
payload: { value: true },
},
},
},
});
expect(reflagInstance["_config"].fallbackFlags).toStrictEqual({
flag1: {
key: "flag1",
config: undefined,
isEnabled: true,
},
flag2: {
key: "flag2",
isEnabled: true,
config: {
key: "config1",
payload: { value: true },
},
},
});
});
it("should create a client instance with valid options", () => {
const client = new ReflagClient(validOptions);
expect(client).toBeInstanceOf(ReflagClient);
expect(client["_config"].apiBaseUrl).toBe("https://api.example.com/");
expect(client["_config"].refetchInterval).toBe(FLAGS_REFETCH_MS);
expect(client["_config"].staleWarningInterval).toBe(FLAGS_REFETCH_MS * 5);
expect(client.logger).toBeDefined();
expect(client.httpClient).toBe(validOptions.httpClient);
expect(client["_config"].headers).toEqual(expectedHeaders);
expect(client["batchBuffer"]).toMatchObject({
maxSize: 99,
intervalMs: 10001,
});
expect(client["_config"].fallbackFlags).toEqual({
key: {
key: "key",
isEnabled: true,
},
});
expect(client["_config"].flagsFetchRetries).toBe(2);
});
it("should route messages to the supplied logger", () => {
const client = new ReflagClient(validOptions);
const actualLogger = client.logger!;
actualLogger.debug("debug message");
actualLogger.info("info message");
actualLogger.warn("warn message");
actualLogger.error("error message");
expect(logger.debug).toHaveBeenCalledWith(
expect.stringMatching("debug message"),
);
expect(logger.info).toHaveBeenCalledWith(
expect.stringMatching("info message"),
);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringMatching("warn message"),
);
expect(logger.error).toHaveBeenCalledWith(
expect.stringMatching("error message"),
);
});
it("should create a client instance with default values for optional fields", () => {
const client = new ReflagClient({
secretKey: "validSecretKeyWithMoreThan22Chars",
});
expect(client["_config"].apiBaseUrl).toBe(API_BASE_URL);
expect(client["_config"].refetchInterval).toBe(FLAGS_REFETCH_MS);
expect(client["_config"].staleWarningInterval).toBe(FLAGS_REFETCH_MS * 5);
expect(client.httpClient).toBe(fetchClient);
expect(client["_config"].headers).toEqual(expectedHeaders);
expect(client["_config"].fallbackFlags).toBeUndefined();
expect(client["batchBuffer"]).toMatchObject({
maxSize: BATCH_MAX_SIZE,
intervalMs: BATCH_INTERVAL_MS,
});
});
it("should throw an error if options are invalid", () => {
let invalidOptions: any = null;
expect(() => new ReflagClient(invalidOptions)).toThrow(
"options must be an object",
);
invalidOptions = { ...validOptions, secretKey: "shortKey" };
expect(() => new ReflagClient(invalidOptions)).toThrow(
"invalid secretKey specified",
);
invalidOptions = { ...validOptions, host: 123 };
expect(() => new ReflagClient(invalidOptions)).toThrow(
"host must be a string",
);
invalidOptions = {
...validOptions,
logger: "invalidLogger" as any,
};
expect(() => new ReflagClient(invalidOptions)).toThrow(
"logger must be an object",
);
invalidOptions = {
...validOptions,
httpClient: "invalidHttpClient" as any,
};
expect(() => new ReflagClient(invalidOptions)).toThrow(
"httpClient must be an object",
);
invalidOptions = {
...validOptions,
batchOptions: "invalid" as any,
};
expect(() => new ReflagClient(invalidOptions)).toThrow(
"batchOptions must be an object",
);
invalidOptions = {
...validOptions,
fallbackFlags: "invalid" as any,
};
expect(() => new ReflagClient(invalidOptions)).toThrow(
"fallbackFlags must be an array or object",
);
});
it("should create a new flag events rate-limiter", () => {
const client = new ReflagClient(validOptions);
expect(client["rateLimiter"]).toBeDefined();
expect(newRateLimiter).toHaveBeenCalledWith(
FLAG_EVENT_RATE_LIMITER_WINDOW_SIZE_MS,
);
});
it("should not register an exit flush handler if `batchOptions.flushOnExit` is false", () => {
new ReflagClient({
...validOptions,
batchOptions: { ...validOptions.batchOptions, flushOnExit: false },
});
expect(triggerOnExit).not.toHaveBeenCalled();
});
it("should not register an exit flush handler if `offline` is true", () => {
new ReflagClient({
...validOptions,
offline: true,
});
expect(triggerOnExit).not.toHaveBeenCalled();
});
it.each([undefined, true])(
"should register an exit flush handler if `batchOptions.flushOnExit` is `%s`",
(flushOnExit) => {
new ReflagClient({
...validOptions,
batchOptions: { ...validOptions.batchOptions, flushOnExit },
});
expect(triggerOnExit).toHaveBeenCalledWith(expect.any(Function));
},
);
it.each([
["https://api.example.com", "https://api.example.com/bulk"],
["https://api.example.com/", "https://api.example.com/bulk"],
["https://api.example.com/path", "https://api.example.com/path/bulk"],
["https://api.example.com/path/", "https://api.example.com/path/bulk"],
])(
"should build the URLs correctly %s -> %s",
async (apiBaseUrl, expectedUrl) => {
const client = new ReflagClient({
...validOptions,
apiBaseUrl,
});
await client.updateUser("user_id");
await client.flush();
expect(httpClient.post).toHaveBeenCalledWith(
expectedUrl,
expect.any(Object),
expect.any(Object),
);
},
);
});
describe("bindClient", () => {
const client = new ReflagClient(validOptions);
const context = {
user,
company,
other: otherContext,
};
beforeEach(() => {
vi.mocked(httpClient.post).mockResolvedValue({ body: { success: true } });
client["rateLimiter"].clearStale(true);
});
it("should return a new client instance with the `user`, `company` and `other` set", async () => {
const newClient = client.bindClient(context);
await client.flush();
expect(newClient.user).toEqual(user);
expect(newClient.company).toEqual(company);
expect(newClient.otherContext).toEqual(otherContext);
expect(newClient).toBeInstanceOf(BoundReflagClient);
expect(newClient).not.toBe(client); // Ensure a new instance is returned
expect(newClient["_options"]).toEqual({
enableTracking: true,
...context,
});
});
it("should update user in Reflag when called", async () => {
client.bindClient({ user: context.user });
await client.flush();
const { id: _, ...attributes } = context.user;
expect(httpClient.post).toHaveBeenCalledWith(
BULK_ENDPOINT,
expectedHeaders,
[
{
type: "user",
userId: user.id,
attributes: attributes,
context: undefined,
},
],
);
expect(httpClient.post).toHaveBeenCalledOnce();
});
it("should update company in Reflag when called", async () => {
client.bindClient({ company: context.company, meta: { active: true } });
await client.flush();
const { id: _, ...attributes } = context.company;
expect(httpClient.post).toHaveBeenCalledWith(
BULK_ENDPOINT,
expectedHeaders,
[
{
type: "company",
companyId: company.id,
attributes: attributes,
context: {
active: true,
},
},
],
);
expect(httpClient.post).toHaveBeenCalledOnce();
});
it("should not update `company` or `user` in Reflag when `enableTracking` is `false`", async () => {
client.bindClient({
user: context.user,
company: context.company,
enableTracking: false,
});
await client.flush();
expect(httpClient.post).not.toHaveBeenCalled();
});
it("should throw an error if `user` is invalid", () => {
expect(() =>
client.bindClient({ user: "bad_attributes" as any }),
).toThrow("validation failed: user must be an object if given");
expect(() => client.bindClient({ user: { id: {} as any } })).toThrow(
"validation failed: user.id must be a string or number if given",
);
});
it("should throw an error if `company` is invalid", () => {
expect(() =>
client.bindClient({ company: "bad_attributes" as any }),
).toThrow("validation failed: company must be an object if given");
expect(() => client.bindClient({ company: { id: {} as any } })).toThrow(
"validation failed: company.id must be a string or number if given",
);
});
it("should throw an error if `other` is invalid", () => {
expect(() =>
client.bindClient({ other: "bad_attributes" as any }),
).toThrow("validation failed: other must be an object");
});
it("should throw an error if `enableTracking` is invalid", () => {
expect(() =>
client.bindClient({ enableTracking: "bad_attributes" as any }),
).toThrow("validation failed: enableTracking must be a boolean");
});
it("should allow context without id", () => {
const c = client.bindClient({
user: { id: undefined, name: "userName" },
company: { id: undefined, name: "companyName" },
});
expect(c.user?.id).toBeUndefined();
expect(c.company?.id).toBeUndefined();
});
});
describe("updateUser", () => {
const client = new ReflagClient(validOptions);
beforeEach(() => {
client["rateLimiter"].clearStale(true);
});
// try with both string and number IDs
test.each([
{ id: "user123", age: 1, name: "John" },
{ id: 42, age: 1, name: "John" },
])("should successfully update the user", async (testUser) => {
const response = { status: 200, body: { success: true } };
httpClient.post.mockResolvedValue(response);
await client.updateUser(testUser.id, {
attributes: { age: 2, brave: false },
meta: {
active: true,
},
});
await client.flush();
expect(httpClient.post).toHaveBeenCalledWith(
BULK_ENDPOINT,
expectedHeaders,
[
{
type: "user",
userId: testUser.id,
attributes: { age: 2, brave: false },
context: { active: true },
},
],
);
expect(logger.debug).toHaveBeenCalledWith(
expect.stringMatching("post request to "),
response,
);
});
it("should log an error if the post request throws", async () => {
const error = new Error("Network error");
httpClient.post.mockRejectedValue(error);
await client.updateUser(user.id);
await client.flush();
expect(logger.error).toHaveBeenCalledWith(
expect.stringMatching("post request to .* failed with error"),
error,
);
});
it("should log if API call returns false", async () => {
const response = { status: 200, body: { success: false } };
httpClient.post.mockResolvedValue(response);
await client.updateUser(user.id);
await client.flush();
expect(logger.warn).toHaveBeenCalledWith(
expect.stringMatching("invalid response received from server for"),
JSON.stringify(response),
);
});
it("should throw an error if opts are not valid or the user is not set", async () => {
await expect(
client.updateUser(user.id, "bad_opts" as any),
).rejects.toThrow("validation failed: options must be an object");
await expect(
client.updateUser(user.id, { attributes: "bad_attributes" as any }),
).rejects.toThrow("attributes must be an object");
await expect(
client.updateUser(user.id, { meta: "bad_meta" as any }),
).rejects.toThrow("meta must be an object");
});
});
describe("updateCompany", () => {
const client = new ReflagClient(validOptions);
beforeEach(() => {
client["rateLimiter"].clearStale(true);
});
test.each([
{
id: "company123",
employees: 100,
name: "Acme Inc.",
userId: "user123",
},
{ id: 42, employees: 100, name: "Acme Inc.", userId: 42 },
])(`should successfully update the company`, async (testCompany) => {
const response = { status: 200, body: { success: true } };
httpClient.post.mockResolvedValue(response);
await client.updateCompany(testCompany.id, {
attributes: { employees: 200, bankrupt: false },
meta: { active: true },
userId: testCompany.userId,
});
await client.flush();
expect(httpClient.post).toHaveBeenCalledWith(
BULK_ENDPOINT,
expectedHeaders,
[
{
type: "company",
companyId: testCompany.id,
attributes: { employees: 200, bankrupt: false },
context: { active: true },
userId: testCompany.userId,
},
],
);
expect(logger.debug).toHaveBeenCalledWith(
expect.stringMatching("post request to .*"),
response,
);
});
it("should log an error if the post request throws", async () => {
const error = new Error("Network error");
httpClient.post.mockRejectedValue(error);
await client.updateCompany(company.id, {});
await client.flush();
expect(logger.error).toHaveBeenCalledWith(
expect.stringMatching("post request to .* failed with error"),
error,
);
});
it("should log an error if API responds with success: false", async () => {
const response = {
status: 200,
body: { success: false },
};
httpClient.post.mockResolvedValue(response);
await client.updateCompany(company.id, {});
await client.flush();
expect(logger.warn).toHaveBeenCalledWith(
expect.stringMatching("invalid response received from server for"),
JSON.stringify(response),
);
});
it("should throw an error if company is not valid", async () => {
await expect(
client.updateCompany(company.id, "bad_opts" as any),
).rejects.toThrow("validation failed: options must be an object");
await expect(
client.updateCompany(company.id, {
attributes: "bad_attributes" as any,
}),
).rejects.toThrow("attributes must be an object");
await expect(
client.updateCompany(company.id, { meta: "bad_meta" as any }),
).rejects.toThrow("meta must be an object");
});
});
describe("track", () => {
const client = new ReflagClient(validOptions);
beforeEach(() => {
client["rateLimiter"].clearStale(true);
});
test.each([
{ id: "user123", age: 1, name: "John" },
{ id: 42, age: 1, name: "John" },
])("should successfully track the flag usage", async (testUser) => {
const response = {
status: 200,
body: { success: true },
};
httpClient.post.mockResolvedValue(response);
await client.bindClient({ user: testUser, company }).track(event.event, {
attributes: event.attrs,
meta: { active: true },
});
await client.flush();
expect(httpClient.post).toHaveBeenCalledWith(
BULK_ENDPOINT,
expectedHeaders,
[
expect.objectContaining({
type: "company",
}),
expect.objectContaining({
type: "user",
}),
{
attributes: {
key: "value",
},
context: {
active: true,
},
event: "flag-event",
type: "event",
userId: testUser.id,
companyId: company.id,
},
],
);
expect(logger.debug).toHaveBeenCalledWith(
expect.stringMatching("post request to"),
response,
);
});
it("should successfully track the flag usage including user and company", async () => {
httpClient.post.mockResolvedValue({
status: 200,
body: { success: true },
});
await client.bindClient({ user }).track(event.event, {
companyId: "otherCompanyId",
attributes: event.attrs,
meta: { active: true },
});
await client.flush();
expect(httpClient.post).toHaveBeenCalledWith(
BULK_ENDPOINT,
expectedHeaders,
[
expect.objectContaining({
type: "user",
}),
{
attributes: {
key: "value",
},
context: {
active: true,
},
event: "flag-event",
companyId: "otherCompanyId",
type: "event",
userId: "user123",
},
],
);
});
it("should log an error if the post request fails", async () => {
const error = new Error("Network error");
httpClient.post.mockRejectedValue(error);
await client.bindClient({ user }).track(event.event);
await client.flush();
expect(logger.error).toHaveBeenCalledWith(
expect.stringMatching("post request to .* failed with error"),
error,
);
});
it("should log if the API call returns false", async () => {
const response = {
status: 200,
body: { success: false },
};
httpClient.post.mockResolvedValue(response);
await client.bindClient({ user }).track(event.event);
await client.flush();
expect(logger.warn).toHaveBeenCalledWith(
expect.stringMatching("invalid response received from server for "),
JSON.stringify(response),
);
});
it("should log if user is not set", async () => {
const boundClient = client.bindClient({ company });
await boundClient.track("hello");
expect(httpClient.post).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
expect.stringMatching("no user set, cannot track event"),
);
});
it("should throw an error if event is invalid", async () => {
const boundClient = client.bindClient({ company, user });
await expect(boundClient.track(undefined as any)).rejects.toThrow(
"event must be a string",
);
await expect(boundClient.track(1 as any)).rejects.toThrow(
"event must be a string",
);
await expect(
boundClient.track(event.event, "bad_opts" as any),
).rejects.toThrow("validation failed: options must be an object");
await expect(
boundClient.track(event.event, {
attributes: "bad_attributes" as any,
}),
).rejects.toThrow("attributes must be an object");
await expect(
boundClient.track(event.event, { meta: "bad_meta" as any }),
).rejects.toThrow("meta must be an object");
});
});
describe("user", () => {
it("should return the undefined if user was not set", () => {
const client = new ReflagClient(validOptions).bindClient({ company });
expect(client.user).toBeUndefined();
});
it("should return the user if user was associated", () => {
const client = new ReflagClient(validOptions).bindClient({ user });
expect(client.user).toEqual(user);
});
});
describe("company", () => {
it("should return the undefined if company was not set", () => {
const client = new ReflagClient(validOptions).bindClient({ user });
expect(client.company).toBeUndefined();
});
it("should return the user if company was associated", () => {
const client = new ReflagClient(validOptions).bindClient({ company });
expect(client.company).toEqual(company);
});
});
describe("otherContext", () => {
it("should return the undefined if custom context was not set", () => {
const client = new ReflagClient(validOptions).bindClient({ company });
expect(client.otherContext).toBeUndefined();
});
it("should return the user if custom context was associated", () => {
const client = new ReflagClient(validOptions).bindClient({
other: otherContext,
});
expect(client.otherContext).toEqual(otherContext);
});
});
describe("initialize", () => {
it("should initialize the client", async () => {
const client = new ReflagClient(validOptions);
const get = vi
.spyOn(client["flagsCache"], "get")
.mockReturnValue(undefined);
const refresh = vi
.spyOn(client["flagsCache"], "refresh")
.mockResolvedValue(undefined);
await client.initialize();
await client.initialize();
await client.initialize();
expect(refresh).toHaveBeenCalledTimes(1);
expect(get).not.toHaveBeenCalled();
});
it("should call the backend to obtain flags", async () => {
const client = new ReflagClient(validOptions);
httpClient.get.mockResolvedValue({
ok: true,
status: 200,
});
await client.initialize();
expect(httpClient.get).toHaveBeenCalledWith(
`https://api.example.com/features`,
expectedHeaders,
API_TIMEOUT_MS,
);
});
});
describe("flush", () => {
it("should flush all bulk data", async () => {
const client = new ReflagClient(validOptions);
await client.updateUser(user.id, { attributes: { age: 2 } });
await client.updateUser(user.id, { attributes: { age: 3 } });
await client.updateUser(user.id, { attributes: { name: "Jane" } });
await client.flush();
expect(httpClient.post).toHaveBeenCalledWith(
BULK_ENDPOINT,
expectedHeaders,
[
{
type: "user",
userId: user.id,
attributes: { age: 2 },
},
{
type: "user",
userId: user.id,
attributes: { age: 3 },
},
{
type: "user",
userId: user.id,
attributes: { name: "Jane" },
},
],
);
});
it("should not flush all bulk data if `offline` is true", async () => {
const client = new ReflagClient({
...validOptions,
offline: true,
});
await client.updateUser(user.id, { attributes: { age: 2 } });
await client.flush();
expect(httpClient.post).not.toHaveBeenCalled();
});
});
describe("getFlag", () => {
let client: ReflagClient;
beforeEach(async () => {
httpClient.get.mockResolvedValue({
ok: true,
status: 200,
body: {
success: true,
...flagDefinitions,
},
});
client = new ReflagClient(validOptions);
httpClient.post.mockResolvedValue({
status: 200,
body: { success: true },
});
});
it("returns a flag", async () => {
await client.initialize();
const flag = client.getFlag(
{
company,
user,
other: otherContext,
},
"flag1",
);
expect(flag).toStrictEqual({
key: "flag1",
isEnabled: true,
config: {
key: "config-1",
payload: { something: "else" },
},
track: expect.any(Function),
});
});
it("`track` sends all expected events when `enableTracking` is `true`", async () => {
const context = {
company,
user,
other: otherContext,
};
// test that the flag is returned
await client.initialize();
const flag = client.getFlag(
{
...context,
meta: {
active: true,
},
enableTracking: true,
},
"flag1",
);
await flag.track();
await client.flush();
expect(httpClient.post).toHaveBeenCalledWith(
BULK_ENDPOINT,
expectedHeaders,
[
{
attributes: {
employees: 100,
name: "Acme Inc.",
},
companyId: "company123",
context: {
active: true,
},
type: "company",
userId: undefined,
},
{
attributes: {
age: 1,
name: "John",
},
context: {
active: true,
},
type: "user",
userId: "user123",
},
{
type: "event",
event: "flag1",
userId: user.id,
companyId: company.id,
},
],
);
});
it("`isEnabled` sends `check` event", async () => {
const context = {
company,
user,
other: otherContext,
};
// test that the flag is returned
await client.initialize();
const flag = client.getFlag(context, "flag1");
// trigger `check` event
expect(flag.isEnabled).toBe(true);
await client.flush();
const checkEvents = httpClient.post.mock.calls
.flatMap((call) => call[2])
.filter((e) => e.action === "check");
expect(checkEvents).toStrictEqual([
{
type: "feature-flag-event",
action: "check",
key: "flag1",
targetingVersion: 1,
evalResult: true,
evalContext: context,
evalRuleResults: [true],
evalMissingFields: [],
},
]);
});
it("`isEnabled` warns about missing context fields", async () => {
const context = {
company,
user,
other: otherContext,
};
// test that the flag is returned
await client.initialize();
const flag = client.getFlag(context, "flag2");
// trigger the warning
expect(flag.isEnabled).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(
"flag targeting rules might not be correctly evaluated due to missing context fields.",
{
flag2: ["attributeKey"],
},
);
});
it("`isEnabled` should not warn about missing context fields if not needed", async () => {
const context = {
company,
user,
other: otherContext,
};
// test that the flag is returned
await client.initialize();
const flag = client.getFlag(context, "flag1");
// should not trigger the warning
expect(flag.isEnabled).toBe(true);
expect(logger.warn).not.toHaveBeenCalled();
});
it("`config` sends `check` event", async () => {
const context = {
company,
user,
other: otherContext,
};
// test that the flag is returned
await client.initialize();
const flag = client.getFlag(context, "flag1");
// trigger `check` event
expect(flag.config).toBeDefined();
await client.flush();
const checkEvents = httpClient.post.mock.calls
.flatMap((call) => call[2])
.filter((e) => e.action === "check-config");
expect(checkEvents).toStrictEqual([
{
type: "feature-flag-event",
action: "check-config",
key: "flag1",
evalResult: {
key: "config-1",
payload: {
something: "else",
},
},
targetingVersion: 1,
evalContext: context,
evalRuleResults: [true],
evalMissingFields: [],
},
]);
});
it("sends events for unknown flags", async () => {
const context: Context = {
company,
user,
other: otherContext,
};
// test that the flag is returned
await client.initialize();
const flag = client.getFlag(context, "unknown-flag");
// trigger `check` event
expect(flag.isEnabled).toBe(false);
await flag.track();
await client.flush();
const checkEvents = httpClient.post.mock.calls
.flatMap((call) => call[2])
.filter((e) => e.type === "feature-flag-event");
expect(checkEvents).toStrictEqual([
{
type: "feature-flag-event",
action: "check",
key: "unknown-flag",
targetingVersion: undefined,
evalContext: context,
evalResult: false,
evalRuleResults: undefined,
evalMissingFields: undefined,
},
]);
});
it("sends company/user and track events", async () => {
const context: Context = {
company,
user,
other: otherContext,
};
// test that the flag is returned
await client.initialize();
const flag = client.getFlag(context, "flag1");
// trigger `check` event
await flag.track();
await client.flush();
const checkEvents = httpClient.post.mock.calls
.flatMap((call) => call[2])
.filter(
(e) =>
e.type === "company" || e.type === "user" || e.type === "event",
);
expect(checkEvents).toStrictEqual([
{
type: "company",
companyId: "company123",
attributes: {
employees: 100,
name: "Acme Inc.",
},
userId: undefined, // this is a bug, will fix in separate PR
context: undefined,
},
{
type: "user",
userId: "user123",
attributes: {
age: 1,
name: "John",
},
context: undefined,
},
{
type: "event",
event: "flag1",
userId: user.id,
companyId: company.id,
context: undefined,
attributes: undefined,
},
]);
});
});
describe("getFlags", () => {
let client: ReflagClient;
beforeEach(async () => {
httpClient.get.mockResolvedValue({
ok: true,
status: 200,
body: {
success: true,
...flagDefinitions,
},
});
client = new ReflagClient(validOptions);
client["rateLimiter"].clearStale(true);
httpClient.post.mockResolvedValue({
ok: true,
status: 200,
body: { success: true },
});
});
it("should return evaluated flags", async () => {
httpClient.post.mockClear(); // not interested in updates
await client.initialize();
const result = client.getFlags({
company,
user,
other: otherContext,
});
expect(result).toStrictEqual({
flag1: {
key: "flag1",
isEnabled: true,
config: {
key: "config-1",
payload: {
something: "else",
},
},
track: expect.any(Function),
},
flag2: {
key: "flag2",
isEnabled: false,
config: { key: undefined, payload: undefined },
track: expect.any(Function),
},
});
await client.flush();
expect(httpClient.post).toHaveBeenCalledTimes(1);
});
it("should properly define the rate limiter key", async () => {
const isAllowedSpy = vi.spyOn(client["rateLimiter"], "isAllowed");
await client.initialize();
client.getFlags({ user, company, other: otherContext });
expect(isAllowedSpy).toHaveBeenCalledWith("1GHpP+QfYperQ0AtD8bWPiRE4H0=");
});
it("should return evaluated flags when only user is defined", async () => {
httpClient.post.mockClear(); // not interested in updates
await client.initialize();
const flags = client.getFlags({ user });
expect(flags).toStrictEqual({
flag1: {
isEnabled: false,
key: "flag1",
config: {
key: undefined,
payload: undefined,
},
track: expect.any(Function),
},
flag2: {
key: "flag2",
isEnabled: false,
config: { key: undefined, payload: undefined },
track: expect.any(Function),
},
});
await client.flush();
expect(httpClient.post).toHaveBeenCalledTimes(1);
});
it("should return evaluated flags when only company is defined", async () => {
await client.initialize();
const flags = client.getFlags({ company });
// expect will trigger the `isEnabled` getter and send a `check` event
expect(flags).toStrictEqual({
flag1: {
isEnabled: true,
key: "flag1",
config: {
key: "config-1",
payload: {
something: "else",
},
},
track: expect.any(Function),
},
flag2: {
key: "flag2",
isEnabled: false,
config: { key: undefined, payload: undefined },
track: expect.any(Function),
},
});
await client.flush();
expect(httpClient.post).toHaveBeenCalledTimes(1);
});
it("should not send flag events when `enableTracking` is `false`", async () => {
await client.initialize();
const flags = client.getFlags({ company, enableTracking: false });
// expect will trigger the `isEnabled` getter and send a `check` event
expect(flags).toStrictEqual({
flag1: {
isEnabled: true,
key: "flag1",
config: {
key: "config-1",
payload: {
something: "else",
},
},
track: expect.any(Function),
},
flag2: {
key: "flag2",
isEnabled: false,
config: { key: undefined, payload: undefined },
track: expect.any(Function),
},
});
await client.flush();
expect(httpClient.post).not.toHaveBeenCalled();
});
it("should return evaluated flags when only other context is defined", async () => {
await client.initialize();
const flags = client.getFlags({ other: otherContext });
expect(flags).toStrictEqual({
flag1: {
isEnabled: false,
key: "flag1",
config: {
key: undefined,
payload: undefined,
},
track: expect.any(Function),
},
flag2: {
key: "flag2",
isEnabled: false,
config: { key: undefined, payload: undefined },
track: expect.any(Function),
},
});
await client.flush();
});
it("should send `track` with user and company if provided", async () => {
await client.initialize();
const flags = client.getFlags({ company, user });
await client.flush();
await flags.flag1.track();
await client.flush();
expect(httpClient.post).toHaveBeenCalledTimes(2);
// second call includes the track event
const events = httpClient.post.mock.calls[1][2].filter(
(e: any) => e.type === "event",
);
expect(events).toStrictEqual([
{
event: "flag1",
type: "event",
userId: "user123",
companyId: "company123",
attributes: undefined,
context: undefined,
},
]);
});
it("should send `track` with user if provided", async () => {
await client.initialize();
const flags = client.getFlags({ user });
await client.flush();
await flags.flag1.track();
await client.flush();
expect(httpClient.post).toHaveBeenCalledTimes(2);
const emptyEvents = httpClient.post.mock.calls[0][2].filter(
(e: any) => e.type === "event",
);
expect(emptyEvents).toStrictEqual([]);
// second call includes the track event
const events = httpClient.post.mock.calls[1][2].filter(
(e: any) => e.type === "event",
);
expect(events).toStrictEqual([
{
event: "flag1",
type: "event",
userId: "user123",
companyId: undefined,
attributes: undefined,
context: undefined,
},
]);
});
it("should not send `track` with only company if no user is provided", async () => {
// we do not accept track events without a userId
await client.initialize();
const flag = client.getFlags({ company });
await flag.flag1.track();
await client.flush();
expect(httpClient.post).toHaveBeenCalledTimes(1);
const events = httpClient.post.mock.calls[0][2].filter(
(e: any) => e.type === "event",
);
expect(events).toStrictEqual([]);
});
it("`isEnabled` does not send `check` event", async () => {
const context = {
company,
user,
other: otherContext,
};
// test that the flag is returned
await client.initialize();
const flag = client.getFlags(context);
// trigger `check` event
expect(flag.flag1.isEnabled).toBe(true);
await client.flush();
const checkEvents = httpClient.post.mock.calls
.flatMap((call) => call[2])
.filter((e) => e.action === "check");
expect(checkEvents).toStrictEqual([]);
});
it("`config` does not send `check` event", async () => {
const context = {
company,
user,
other: otherContext,
};
// test that the flag is returned
await client.initialize();
const flag = client.getFlags(context);
// attempt to trigger `check` event
expect(flag.flag1.config).toBeDefined();
await client.flush();
const checkEvents = httpClient.post.mock.calls
.flatMap((call) => call[2])
.filter((e) => e.action === "check-config");
expect(checkEvents).toStrictEqual([]);
});
it("sends company/user events", async () => {
const context: Context = {
company,
user,
other: otherContext,
};
// test that the flag is returned
await client.initialize();
client.getFlags(context);
// trigger `check` event
await client.flush();
const checkEvents = httpClient.post.mock.calls
.flatMap((call) => call[2])
.filter(
(e) =>
e.type === "company" || e.type === "user" || e.type === "event",
);
expect(checkEvents).toStrictEqual([
{
type: "company",
companyId: "company123",
attributes: {
employees: 100,
name: "Acme Inc.",
},
userId: undefined, // this is a bug, will fix in separate PR
context: undefined,
},
{
type: "user",
userId: "user123",
attributes: {
age: 1,
name: "John",
},
context: undefined,
},
]);
});
it("should use fallback flags when `getFlagDefinitions` returns `undefined`", async () => {
httpClient.get.mockResolvedValue({
success: false,
});
await client.initialize();
const result = client.getFlag(
{ user: { id: "user123" }, enableTracking: true },
"key",
);
expect(result).toStrictEqual({
key: "key",
isEnabled: true,
config: { key: undefined, payload: undefined },
track: expect.any(Function),
});
expect(logger.warn).toHaveBeenCalledWith(
expect.stringMatching(
"no flag definitions available, using fallback flags",
),
);
await client.flush();
expect(httpClient.post).toHaveBeenCalledTimes(1);
expect(httpClient.post).toHaveBeenCalledWith(
BULK_ENDPOINT,
expectedHeaders,
[
expect.objectContaining({ type: "user" }),
expect.objectContaining({
type: "feature-flag-event",
action: "check-config",
key: "key",
}),
expect.objectContaining({
type: "feature-flag-event",
action: "check",
key: "key",
evalResult: true,
}),
],
);
});
it("should not fail if sendFlagEvent fails to send check event", async () => {
httpClient.post.mockResolvedValue({
status: 200,
body: { success: true },
});
await client.initialize();
httpClient.post.mockRejectedValue(new Error("Network error"));
const context = { user, company, other: otherContext };
const result = client.getFlags(context);
// Trigger a flag check
expect(result.flag1).toStrictEqual({
key: "flag1",
isEnabled: true,
track: expect.any(Function),
config: {
key: "config-1",
payload: {
something: "else",
},
},
});
await client.flush();
expect(logger.error).toHaveBeenCalledWith(
expect.stringMatching("post request .* failed with error"),
expect.any(Error),
);
});
it("should use flag overrides", async () => {
await client.initialize();
const context = { user, company, other: otherContext };
const pristineResults = client.getFlags(context);
expect(pristineResults).toStrictEqual({
flag1: {
key: "flag1",
isEnabled: true,
config: {
key: "config-1",
payload: {
something: "else",
},
},
track: expect.any(Function),
},
flag2: {
key: "flag2",
isEnabled: false,
config: { key: undefined, payload: undefined },
track: expect.any(Function),
},
});
client.flagOverrides = {
flag1: false,
};
const flags = client.getFlags(context);
expect(flags).toStrictEqual({
flag1: {
key: "flag1",
isEnabled: false,
config: { key: undefined, payload: undefined },
track: expect.any(Function),
},
flag2: {
key: "flag2",
isEnabled: false,
config: { key: undefined, payload: undefined },
track: expect.any(Function),
},
});
client.clearFlagOverrides();
const flags2 = client.getFlags(context);
expect(flags2).toStrictEqual({
...pristineResults,
flag1: {
...pristineResults.flag1,
track: expect.any(Function),
},
flag2: {
...pristineResults.flag2,
track: expect.any(Function),
},
});
});
it("should use flag overrides from function", async () => {
await client.initialize();
const context = { user, company, other: otherContext };
const pristineResults = client.getFlags(context);
expect(pristineResults).toStrictEqual({
flag1: {
key: "flag1",
isEnabled: true,
config: {
key: "config-1",
payload: {
something: "else",
},
},
track: expect.any(Function),
},
flag2: {
key: "flag2",
isEnabled: false,
config: { key: undefined, payload: undefined },
track: expect.any(Function),
},
});
client.flagOverrides = (_context: Context) => {
expect(context).toStrictEqual(context);
return {
flag1: { isEnabled: false },
flag2: true,
flag3: {
isEnabled: true,
config: {
key: "config-1",
payload: { something: "else" },
},
},
};
};
const flags = client.getFlags(context);
expect(flags).toStrictEqual({
flag1: {
key: "flag1",
isEnabled: false,
config: { key: undefined, payload: undefined },
track: expect.any(Function),
},
flag2: {
key: "flag2",
isEnabled: true,
config: { key: undefined, payload: undefined },
track: expect.any(Function),
},
flag3: {
key: "flag3",
isEnabled: true,
config: {
key: "config-1",
payload: { something: "else" },
},
track: expect.any(Function),
},
});
});
});
describe("getFlagsForBootstrap", () => {
let client: ReflagClient;
beforeEach(async () => {
httpClient.get.mockResolvedValue({
ok: true,
status: 200,
body: {
success: true,
...flagDefinitions,
},
});
client = new ReflagClient(validOptions);
client["rateLimiter"].clearStale(true);
httpClient.post.mockResolvedValue({
ok: true,
status: 200,
body: { success: true },
});
});
it("should return raw flags without wrapper functions", async () => {
httpClient.post.mockClear(); // not interested in updates
await client.initialize();
const result = client.getFlagsForBootstrap({
company,
user,
other: otherContext,
enableTracking: true,
});
expect(result).toStrictEqual({
context: {
company,
user,
other: otherContext,
enableTracking: true,
},
flags: {
flag1: {
key: "flag1",
isEnabled: true,
targetingVersion: 1,
config: {
key: "config-1",
payload: {
something: "else",
},
targetingVersion: 1,
missingContextFields: [],
ruleEvaluationResults: [true],
},
ruleEvaluationResults: [true],
missingContextFields: [],
},
flag2: {
key: "flag2",
isEnabled: false,
targetingVersion: 2,
config: {
key: undefined,
payload: undefined,
targetingVersion: undefined,
missingContextFields: [],
ruleEvaluationResults: [],
},
ruleEvaluationResults: [false],
missingContextFields: ["attributeKey"],
},
},
});
// Should not have track function like regular getFlags
expect(result.flags.flag1).not.toHaveProperty("track");
expect(result.flags.flag2).not.toHaveProperty("track");
await client.flush();
expect(httpClient.post).toHaveBeenCalledTimes(1);
});
it("should return raw flags when only user is defined", async () => {
httpClient.post.mockClear(); // not interested in updates
await client.initialize();
const flags = client.getFlagsForBootstrap({ user });
expect(flags).toStrictEqual({
context: {
user,
enableTracking: true,
},
flags: {
flag1: {
key: "flag1",
isEnabled: false,
targetingVersion: 1,
config: {
key: undefined,
payload: undefined,
targetingVersion: 1,
missingContextFields: ["company.id"],
ruleEvaluationResults: [false],
},
ruleEvaluationResults: [false],
missingContextFields: ["company.id"],
},
flag2: {
key: "flag2",
isEnabled: false,
targetingVersion: 2,
config: {
key: undefined,
payload: undefined,
targetingVersion: undefined,
missingContextFields: [],
ruleEvaluationResults: [],
},
ruleEvaluationResults: [false],
missingContextFields: ["company.id"],
},
},
});
// Should not have track function
expect(flags.flags.flag1).not.toHaveProperty("track");
expect(flags.flags.flag2).not.toHaveProperty("track");
await client.flush();
expect(httpClient.post).toHaveBeenCalledTimes(1);
});
it("should return raw flags when only company is defined", async () => {
await client.initialize();
const flags = client.getFlagsForBootstrap({ company });
expect(flags).toStrictEqual({
context: {
company,
enableTracking: true,
},
flags: {
flag1: {
key: "flag1",
isEnabled: true,
targetingVersion: 1,
config: {
key: "config-1",
payload: {
something: "else",
},
targetingVersion: 1,
missingContextFields: [],
ruleEvaluationResults: [true],
},
ruleEvaluationResults: [true],
missingContextFields: [],
},
flag2: {
key: "flag2",
isEnabled: false,
targetingVersion: 2,
config: {
key: undefined,
payload: undefined,
targetingVersion: undefined,
missingContextFields: [],
ruleEvaluationResults: [],
},
ruleEvaluationResults: [false],
missingContextFields: ["attributeKey"],
},
},
});
// Should not have track function
expect(flags.flags.flag1).not.toHaveProperty("track");
expect(flags.flags.flag2).not.toHaveProperty("track");
});
it("should return raw flags when only other context is defined", async () => {
await client.initialize();
const flags = client.getFlagsForBootstrap({ other: otherContext });
expect(flags).toStrictEqual({
context: {
other: otherContext,
enableTracking: true,
},
flags: {
flag1: {
key: "flag1",
isEnabled: false,
targetingVersion: 1,
config: {
key: undefined,
payload: undefined,
targetingVersion: 1,
missingContextFields: ["company.id"],
ruleEvaluationResults: [false],
},
ruleEvaluationResults: [false],
missingContextFields: ["company.id"],
},
flag2: {
key: "flag2",
isEnabled: false,
targetingVersion: 2,
config: {
key: undefined,
payload: undefined,
targetingVersion: undefined,
missingContextFields: [],
ruleEvaluationResults: [],
},
ruleEvaluationResults: [false],
missingContextFields: ["company.id"],
},
},
});
// Should not have track function
expect(flags.flags.flag1).not.toHaveProperty("track");
expect(flags.flags.flag2).not.toHaveProperty("track");
});
it("should return fallback flags when client is not initialized", async () => {
const flags = client.getFlagsForBootstrap({
company,
user,
other: otherContext,
enableTracking: true,
});
// Should return the fallback flags defined in validOptions
expect(flags).toStrictEqual({
context: {
company,
user,
other: otherContext,
enableTracking: true,
},
flags: {
key: {
isEnabled: true,
key: "key",
},
},
});
});
it("should return fallback flags when flag definitions are not available", async () => {
httpClient.get.mockResolvedValueOnce({
ok: true,
status: 200,
body: {
success: true,
features: [], // No flag definitions
},
});
await client.initialize();
const flags = client.getFlagsForBootstrap({
company,
user,
other: otherContext,
});
expect(flags).toStrictEqual({
context: {
company,
user,
other: otherContext,
enableTracking: true,
},
flags: {},
});
});
it("should handle enableTracking parameter", async () => {
await client.initialize();
// Test with enableTracking: true (default)
const flagsWithTracking = client.getFlagsForBootstrap({
company,
user,
other: otherContext,
enableTracking: true,
});
// Test with enableTracking: false
const flagsWithoutTracking = client.getFlagsForBootstrap({
company,
user,
other: otherContext,
enableTracking: true,
});
// Both should return the same raw flag structure
expect(flagsWithTracking).toStrictEqual(flagsWithoutTracking);
// Neither should have track functions
expect(flagsWithTracking.flags.flag1).not.toHaveProperty("track");
expect(flagsWithoutTracking.flags.flag1).not.toHaveProperty("track");
});
it("should properly define the rate limiter key", async () => {
const isAllowedSpy = vi.spyOn(client["rateLimiter"], "isAllowed");
await client.initialize();
client.getFlagsForBootstrap({ user, company, other: otherContext });
expect(isAllowedSpy).toHaveBeenCalledWith("1GHpP+QfYperQ0AtD8bWPiRE4H0=");
});
it("should work in offline mode", async () => {
const offlineClient = new ReflagClient({
...validOptions,
offline: true,
});
const flags = offlineClient.getFlagsForBootstrap({
company,
user,
other: otherContext,
enableTracking: true,
});
expect(flags).toStrictEqual({
context: {
company,
user,
other: otherContext,
enableTracking: true,
},
flags: {},
});
});
it("should use fallback flags when provided and no definitions available", async () => {
const fallbackTestFlags = {
fallbackFlag: {
key: "fallbackFlag",
isEnabled: true,
config: { key: "fallback-config", payload: { test: "data" } },
},
};
const clientWithFallback = new ReflagClient({
...validOptions,
fallbackFlags: fallbackTestFlags,
});
// Don't initialize to simulate no flag definitions
const flags = clientWithFallback.getFlagsForBootstrap({
company,
user,
other: otherContext,
enableTracking: true,
});
expect(flags).toStrictEqual({
context: {
company,
user,
other: otherContext,
enableTracking: true,
},
flags: fallbackTestFlags,
});
});
});
});
describe("getFlagsRemote", () => {
let client: ReflagClient;
beforeEach(async () => {
httpClient.get.mockResolvedValue({
ok: true,
status: 200,
body: {
success: true,
remoteContextUsed: true,
features: {
flag1: {
key: "flag1",
targetingVersion: 1,
isEnabled: true,
config: {
key: "config-1",
version: 3,
default: true,
payload: { something: "else" },
missingContextFields: ["funny"],
},
missingContextFields: ["something", "funny"],
},
flag2: {
key: "flag2",
targetingVersion: 2,
isEnabled: false,
missingContextFields: ["another"],
},
flag3: {
key: "flag3",
targetingVersion: 5,
isEnabled: true,
},
},
},
});
client = new ReflagClient(validOptions);
});
afterEach(() => {
httpClient.get.mockClear();
});
it("should return evaluated flags", async () => {
const result = await client.getFlagsRemote("c1", "u1", {
other: otherContext,
});
expect(result).toStrictEqual({
flag1: {
key: "flag1",
isEnabled: true,
config: {
key: "config-1",
payload: { something: "else" },
},
track: expect.any(Function),
},
flag2: {
key: "flag2",
isEnabled: false,
config: { key: undefined, payload: undefined },
track: expect.any(Function),
},
flag3: {
key: "flag3",
isEnabled: true,
config: { key: undefined, payload: undefined },
track: expect.any(Function),
},
});
expect(httpClient.get).toHaveBeenCalledTimes(1);
expect(httpClient.get).toHaveBeenCalledWith(
"https://api.example.com/features/evaluated?context.other.custom=context&context.other.key=value&context.user.id=c1&context.company.id=u1",
expectedHeaders,
API_TIMEOUT_MS,
);
});
it("should not try to append the context if it's empty", async () => {
await client.getFlagsRemote();
expect(httpClient.get).toHaveBeenCalledTimes(1);
expect(httpClient.get).toHaveBeenCalledWith(
"https://api.example.com/features/evaluated?",
expectedHeaders,
API_TIMEOUT_MS,
);
});
});
describe("getFlagRemote", () => {
let client: ReflagClient;
beforeEach(async () => {
httpClient.get.mockResolvedValue({
ok: true,
status: 200,
body: {
success: true,
remoteContextUsed: true,
features: {
flag1: {
key: "flag1",
targetingVersion: 1,
isEnabled: true,
config: {
key: "config-1",
version: 3,
default: true,
payload: { something: "else" },
missingContextFields: ["two"],
},
missingContextFields: ["one", "two"],
},
},
},
});
client = new ReflagClient(validOptions);
});
afterEach(() => {
httpClient.get.mockClear();
});
it("should return evaluated flag", async () => {
const result = await client.getFlagRemote("flag1", "c1", "u1", {
other: otherContext,
});
expect(result).toStrictEqual({
key: "flag1",
isEnabled: true,
track: expect.any(Function),
config: {
key: "config-1",
payload: { something: "else" },
},
});
expect(httpClient.get).toHaveBeenCalledTimes(1);
expect(httpClient.get).toHaveBeenCalledWith(
"https://api.example.com/features/evaluated?context.other.custom=context&context.other.key=value&context.user.id=c1&context.company.id=u1&key=flag1",
expectedHeaders,
API_TIMEOUT_MS,
);
});
it("should not try to append the context if it's empty", async () => {
await client.getFlagRemote("flag1");
expect(httpClient.get).toHaveBeenCalledWith(
"https://api.example.com/features/evaluated?key=flag1",
expectedHeaders,
API_TIMEOUT_MS,
);
});
});
describe("offline mode", () => {
let client: ReflagClient;
beforeEach(async () => {
client = new ReflagClient({
...validOptions,
offline: true,
});
await client.initialize();
});
it("should send not send or fetch anything", async () => {
client.getFlags({});
expect(httpClient.get).toHaveBeenCalledTimes(0);
expect(httpClient.post).toHaveBeenCalledTimes(0);
});
});
describe("BoundReflagClient", () => {
beforeAll(() => {
const response = {
status: 200,
body: { success: true },
};
httpClient.post.mockResolvedValue(response);
httpClient.get.mockResolvedValue({
ok: true,
status: 200,
body: {
success: true,
...flagDefinitions,
},
});
});
const client = new ReflagClient(validOptions);
beforeEach(async () => {
await flushPromises();
await client.flush();
vi.mocked(httpClient.post).mockClear();
client["rateLimiter"].clearStale(true);
});
it("should create a client instance", () => {
expect(client).toBeInstanceOf(ReflagClient);
});
it("should return a new client instance with merged attributes", () => {
const userOverride = { sex: "male", age: 30 };
const companyOverride = { employees: 200, bankrupt: false };
const otherOverride = { key: "new-value" };
const other = { key: "value" };
const newClient = client
.bindClient({
user,
company,
other,
})
.bindClient({
user: { id: user.id, ...userOverride },
company: { id: company.id, ...companyOverride },
other: otherOverride,
});
expect(newClient["_options"]).toEqual({
user: { ...user, ...userOverride },
company: { ...company, ...companyOverride },
other: { ...other, ...otherOverride },
enableTracking: true,
});
});
it("should allow using expected methods when bound to user", async () => {
const boundClient = client.bindClient({ user });
expect(boundClient.user).toEqual(user);
expect(
boundClient.bindClient({ other: otherContext }).otherContext,
).toEqual(otherContext);
boundClient.getFlags();
await boundClient.track("flag");
await client.flush();
expect(httpClient.post).toHaveBeenCalledWith(
BULK_ENDPOINT,
expectedHeaders,
[
expect.objectContaining({ type: "user" }),
{
event: "flag",
type: "event",
userId: "user123",
},
],
);
});
it("should add company ID from the context if not explicitly supplied", async () => {
const boundClient = client.bindClient({ user, company });
boundClient.getFlags();
await boundClient.track("flag");
await client.flush();
expect(httpClient.post).toHaveBeenCalledWith(
BULK_ENDPOINT,
expectedHeaders,
[
expect.objectContaining({ type: "company" }),
expect.objectContaining({ type: "user" }),
{
companyId: "company123",
event: "flag",
type: "event",
userId: "user123",
},
],
);
});
it("should disable tracking within the client if `enableTracking` is `false`", async () => {
const boundClient = client.bindClient({
user,
company,
enableTracking: false,
});
const { track } = boundClient.getFlag("flag2");
await track();
await boundClient.track("flag1");
await client.flush();
expect(httpClient.post).not.toHaveBeenCalled();
});
it("should allow using expected methods", async () => {
const boundClient = client.bindClient({ other: { key: "value" } });
expect(boundClient.otherContext).toEqual({
key: "value",
});
await client.initialize();
boundClient.getFlags();
boundClient.getFlag("flag1");
await boundClient.flush();
});
it("should return raw flags for bootstrap from bound client", async () => {
// Ensure client is properly initialized
await client.initialize();
const boundClient = client.bindClient({
user,
company,
other: otherContext,
enableTracking: true,
});
const result = boundClient.getFlagsForBootstrap();
expect(result).toStrictEqual({
context: {
user,
company,
other: otherContext,
enableTracking: true,
},
flags: {
flag1: {
key: "flag1",
isEnabled: true,
targetingVersion: 1,
config: {
key: "config-1",
payload: {
something: "else",
},
targetingVersion: 1,
missingContextFields: [],
ruleEvaluationResults: [true],
},
ruleEvaluationResults: [true],
missingContextFields: [],
},
flag2: {
key: "flag2",
isEnabled: false,
targetingVersion: 2,
config: {
key: undefined,
payload: undefined,
targetingVersion: undefined,
missingContextFields: [],
ruleEvaluationResults: [],
},
ruleEvaluationResults: [false],
missingContextFields: ["attributeKey"],
},
},
});
// Should not have track function like regular getFlags
expect(result.flags.flag1).not.toHaveProperty("track");
expect(result.flags.flag2).not.toHaveProperty("track");
});
describe("getFlagRemote/getFlagsRemote", () => {
beforeEach(async () => {
httpClient.get.mockClear();
httpClient.get.mockResolvedValue({
ok: true,
status: 200,
body: {
success: true,
remoteContextUsed: true,
features: {
flag1: {
key: "flag1",
targetingVersion: 1,
isEnabled: true,
config: {
key: "config-1",
version: 3,
default: true,
payload: { something: "else" },
missingContextFields: ["else"],
},
},
flag2: {
key: "flag2",
targetingVersion: 2,
isEnabled: false,
missingContextFields: ["something"],
},
},
},
});
});
it("should return evaluated flags", async () => {
const boundClient = client.bindClient({
user,
company,
other: otherContext,
});
const result = await boundClient.getFlagsRemote();
expect(result).toStrictEqual({
flag1: {
key: "flag1",
isEnabled: true,
config: { key: "config-1", payload: { something: "else" } },
track: expect.any(Function),
},
flag2: {
key: "flag2",
isEnabled: false,
config: { key: undefined, payload: undefined },
track: expect.any(Function),
},
});
expect(httpClient.get).toHaveBeenCalledTimes(1);
expect(httpClient.get).toHaveBeenCalledWith(
"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",
expectedHeaders,
API_TIMEOUT_MS,
);
});
it("should return evaluated flag", async () => {
const boundClient = client.bindClient({
user,
company,
other: otherContext,
});
const result = await boundClient.getFlagRemote("flag1");
expect(result).toStrictEqual({
key: "flag1",
isEnabled: true,
config: { key: "config-1", payload: { something: "else" } },
track: expect.any(Function),
});
expect(httpClient.get).toHaveBeenCalledTimes(1);
expect(httpClient.get).toHaveBeenCalledWith(
"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",
expectedHeaders,
API_TIMEOUT_MS,
);
});
});
});
```