#
tokens: 30539/50000 2/327 files (page 6/7)
lines: off (toggle) GitHub
raw markdown copy
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,
      );
    });
  });
});

```
Page 6/7FirstPrevNextLast