#
tokens: 48537/50000 18/327 files (page 4/7)
lines: off (toggle) GitHub
raw markdown copy
This is page 4 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/browser-sdk/src/toolbar/Toolbar.tsx:
--------------------------------------------------------------------------------

```typescript
import { h } from "preact";
import {
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
} from "preact/hooks";

import { ReflagClient } from "../client";
import { IS_SERVER } from "../config";
import { toolbarContainerId } from "../ui/constants";
import { Dialog, DialogContent, DialogHeader, useDialog } from "../ui/Dialog";
import { Logo } from "../ui/icons/Logo";
import { ToolbarPosition } from "../ui/types";
import { parseUnanchoredPosition } from "../ui/utils";

import { FlagSearch, FlagsTable } from "./Flags";
import styles from "./index.css?inline";

const TOOLBAR_HIDE_KEY = "reflag-toolbar-hidden";

export type FlagItem = {
  flagKey: string;
  isEnabled: boolean;
  isEnabledOverride: boolean | null;
};

type Flag = {
  flagKey: string;
  isEnabled: boolean;
  isEnabledOverride: boolean | null;
};

export default function Toolbar({
  reflagClient,
  position,
}: {
  reflagClient: ReflagClient;
  position: ToolbarPosition;
}) {
  const toggleToolbarRef = useRef<HTMLDivElement>(null);
  const dialogContentRef = useRef<HTMLDivElement>(null);
  const [flags, setFlags] = useState<Flag[]>([]);

  const wasHidden =
    !IS_SERVER && sessionStorage.getItem(TOOLBAR_HIDE_KEY) === "true";
  const [isHidden, setIsHidden] = useState(wasHidden);

  const updateFlags = useCallback(() => {
    const rawFlags = reflagClient.getFlags();
    setFlags(
      Object.values(rawFlags)
        .filter((f) => f !== undefined)
        .map(
          (flag) =>
            ({
              flagKey: flag.key,
              isEnabledOverride: flag.isEnabledOverride ?? null,
              isEnabled: flag.isEnabled,
            }) satisfies FlagItem,
        ),
    );
  }, [reflagClient]);

  const hasAnyOverrides = useMemo(() => {
    return flags.some((f) => f.isEnabledOverride !== null);
  }, [flags]);

  useEffect(() => {
    updateFlags();
    reflagClient.on("flagsUpdated", updateFlags);
  }, [reflagClient, updateFlags]);

  const [search, setSearch] = useState<string | null>(null);
  const onSearch = (val: string) => {
    setSearch(val === "" ? null : val);
    dialogContentRef.current?.scrollTo({ top: 0 });
  };

  const sortedFlags = [...flags].sort((a, b) =>
    a.flagKey.localeCompare(b.flagKey),
  );

  const appBaseUrl = reflagClient.getConfig().appBaseUrl;

  const { isOpen, close, toggle } = useDialog();

  const hideToolbar = useCallback(() => {
    if (IS_SERVER) return;
    sessionStorage.setItem(TOOLBAR_HIDE_KEY, "true");
    setIsHidden(true);
    close();
  }, [close]);

  if (isHidden) {
    return null;
  }

  return (
    <div class="toolbar">
      <style dangerouslySetInnerHTML={{ __html: styles }} />
      <ToolbarToggle
        hasAnyOverrides={hasAnyOverrides}
        innerRef={toggleToolbarRef}
        isOpen={isOpen}
        position={position}
        onClick={toggle}
      />
      <Dialog
        close={close}
        containerId={toolbarContainerId}
        isOpen={isOpen}
        position={{
          type: "POPOVER",
          anchor: toggleToolbarRef.current,
          placement: "top-start",
        }}
        showArrow={false}
        strategy="fixed"
      >
        <DialogHeader>
          <FlagSearch onSearch={onSearch} />
          <a
            class="toolbar-header-button"
            data-tooltip="Open Reflag app"
            href={`${appBaseUrl}/env-current`}
          >
            <svg
              width="15"
              height="15"
              xmlns="http://www.w3.org/2000/svg"
              viewBox="0 0 24 24"
              fill="currentColor"
            >
              <path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM9.71002 19.6674C8.74743 17.6259 8.15732 15.3742 8.02731 13H4.06189C4.458 16.1765 6.71639 18.7747 9.71002 19.6674ZM10.0307 13C10.1811 15.4388 10.8778 17.7297 12 19.752C13.1222 17.7297 13.8189 15.4388 13.9693 13H10.0307ZM19.9381 13H15.9727C15.8427 15.3742 15.2526 17.6259 14.29 19.6674C17.2836 18.7747 19.542 16.1765 19.9381 13ZM4.06189 11H8.02731C8.15732 8.62577 8.74743 6.37407 9.71002 4.33256C6.71639 5.22533 4.458 7.8235 4.06189 11ZM10.0307 11H13.9693C13.8189 8.56122 13.1222 6.27025 12 4.24799C10.8778 6.27025 10.1811 8.56122 10.0307 11ZM14.29 4.33256C15.2526 6.37407 15.8427 8.62577 15.9727 11H19.9381C19.542 7.8235 17.2836 5.22533 14.29 4.33256Z" />
            </svg>
          </a>
          <button
            class="toolbar-header-button"
            onClick={hideToolbar}
            data-tooltip="Hide toolbar this session"
          >
            <svg
              width="15"
              height="15"
              xmlns="http://www.w3.org/2000/svg"
              viewBox="0 0 24 24"
              fill="currentColor"
            >
              <path d="M17.8827 19.2968C16.1814 20.3755 14.1638 21.0002 12.0003 21.0002C6.60812 21.0002 2.12215 17.1204 1.18164 12.0002C1.61832 9.62282 2.81932 7.5129 4.52047 5.93457L1.39366 2.80777L2.80788 1.39355L22.6069 21.1925L21.1927 22.6068L17.8827 19.2968ZM5.9356 7.3497C4.60673 8.56015 3.6378 10.1672 3.22278 12.0002C4.14022 16.0521 7.7646 19.0002 12.0003 19.0002C13.5997 19.0002 15.112 18.5798 16.4243 17.8384L14.396 15.8101C13.7023 16.2472 12.8808 16.5002 12.0003 16.5002C9.51498 16.5002 7.50026 14.4854 7.50026 12.0002C7.50026 11.1196 7.75317 10.2981 8.19031 9.60442L5.9356 7.3497ZM12.9139 14.328L9.67246 11.0866C9.5613 11.3696 9.50026 11.6777 9.50026 12.0002C9.50026 13.3809 10.6196 14.5002 12.0003 14.5002C12.3227 14.5002 12.6309 14.4391 12.9139 14.328ZM20.8068 16.5925L19.376 15.1617C20.0319 14.2268 20.5154 13.1586 20.7777 12.0002C19.8603 7.94818 16.2359 5.00016 12.0003 5.00016C11.1544 5.00016 10.3329 5.11773 9.55249 5.33818L7.97446 3.76015C9.22127 3.26959 10.5793 3.00016 12.0003 3.00016C17.3924 3.00016 21.8784 6.87992 22.8189 12.0002C22.5067 13.6998 21.8038 15.2628 20.8068 16.5925ZM11.7229 7.50857C11.8146 7.50299 11.9071 7.50016 12.0003 7.50016C14.4855 7.50016 16.5003 9.51488 16.5003 12.0002C16.5003 12.0933 16.4974 12.1858 16.4919 12.2775L11.7229 7.50857Z" />
            </svg>
          </button>
        </DialogHeader>
        <DialogContent innerRef={dialogContentRef}>
          <FlagsTable
            appBaseUrl={appBaseUrl}
            flags={sortedFlags}
            searchQuery={search?.toLocaleLowerCase() ?? null}
            setIsEnabledOverride={(flagKey, isEnabled) =>
              reflagClient.getFlag(flagKey).setIsEnabledOverride(isEnabled)
            }
          />
        </DialogContent>
      </Dialog>
    </div>
  );
}

function ToolbarToggle({
  isOpen,
  position,
  onClick,
  innerRef,
  hasAnyOverrides,
}: {
  isOpen: boolean;
  position: ToolbarPosition;
  onClick: () => void;
  innerRef: React.RefObject<HTMLDivElement>;
  hasAnyOverrides: boolean;
  children?: preact.VNode;
}) {
  const offsets = parseUnanchoredPosition(position);

  const toggleClasses = ["toolbar-toggle", isOpen ? "open" : undefined].join(
    " ",
  );

  const indicatorClasses = [
    "override-indicator",
    hasAnyOverrides ? "show" : undefined,
  ].join(" ");

  return (
    <div ref={innerRef} class={toggleClasses} style={offsets} onClick={onClick}>
      <div class={indicatorClasses} />
      <Logo height="13px" width="13px" />
    </div>
  );
}

```

--------------------------------------------------------------------------------
/packages/browser-sdk/src/sse.ts:
--------------------------------------------------------------------------------

```typescript
import {
  forgetAuthToken,
  getAuthToken,
  rememberAuthToken,
} from "./feedback/promptStorage";
import { HttpClient } from "./httpClient";
import { Logger, loggerWithPrefix } from "./logger";

interface AblyTokenDetails {
  token: string;
  expires: number;
}

interface AblyTokenRequest {
  keyName: string;
}

const ABLY_TOKEN_ERROR_MIN = 40000;
const ABLY_TOKEN_ERROR_MAX = 49999;

export class AblySSEChannel {
  private isOpen: boolean = false;
  private eventSource: EventSource | null = null;
  private retryInterval: ReturnType<typeof setInterval> | null = null;
  private logger: Logger;

  constructor(
    private userId: string,
    private channel: string,
    private sseBaseUrl: string,
    private messageHandler: (message: any) => void,
    private httpClient: HttpClient,
    logger: Logger,
  ) {
    this.logger = loggerWithPrefix(logger, "[SSE]");

    if (!this.sseBaseUrl.endsWith("/")) {
      this.sseBaseUrl += "/";
    }
  }

  private async refreshTokenRequest() {
    const params = new URLSearchParams({ userId: this.userId });
    const res = await this.httpClient.get({
      path: `/feedback/prompting-auth`,
      params,
    });

    if (res.ok) {
      const body = await res.json();
      if (body.success) {
        delete body.success;
        const tokenRequest: AblyTokenRequest = body;

        this.logger.debug("obtained new token request", tokenRequest);
        return tokenRequest;
      }
    }

    this.logger.error("server did not release a token request", res);
    return;
  }

  private async refreshToken() {
    const cached = getAuthToken(this.userId);
    if (cached && cached.channel === this.channel) {
      this.logger.debug("using existing token", cached.channel, cached.token);
      return cached.token;
    }

    const tokenRequest = await this.refreshTokenRequest();
    if (!tokenRequest) {
      return;
    }

    const url = new URL(
      `keys/${encodeURIComponent(tokenRequest.keyName)}/requestToken`,
      this.sseBaseUrl,
    );

    const res = await fetch(url, {
      method: "post",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify(tokenRequest),
    });

    if (res.ok) {
      const details: AblyTokenDetails = await res.json();
      this.logger.debug("obtained new token", details);

      rememberAuthToken(
        this.userId,
        this.channel,
        details.token,
        new Date(details.expires),
      );
      return details.token;
    }

    this.logger.error("server did not release a token");

    return;
  }

  private async onError(e: Event) {
    if (e instanceof MessageEvent) {
      let errorCode: number | undefined;

      try {
        const errorPayload = JSON.parse(e.data);
        errorCode = errorPayload?.code && Number(errorPayload.code);
      } catch (error: any) {
        this.logger.warn("received unparsable error message", error, e);
      }

      if (
        errorCode &&
        errorCode >= ABLY_TOKEN_ERROR_MIN &&
        errorCode <= ABLY_TOKEN_ERROR_MAX
      ) {
        this.logger.warn("event source token expired, refresh required");
        forgetAuthToken(this.userId);
      }
    } else {
      const connectionState = (e as any)?.target?.readyState;

      if (connectionState === 2) {
        this.logger.debug("event source connection closed", e);
      } else if (connectionState === 1) {
        this.logger.warn("event source connection failed to open", e);
      } else {
        this.logger.warn("event source unexpected error occurred", e);
      }
    }

    this.disconnect();
  }

  private onMessage(e: MessageEvent) {
    let payload: any;

    try {
      if (e.data) {
        const message = JSON.parse(e.data);
        if (message.data) {
          payload = JSON.parse(message.data);
        }
      }
    } catch (error: any) {
      this.logger.warn("received unparsable message", error, e);
      return;
    }

    if (payload) {
      this.logger.debug("received message", payload);

      try {
        this.messageHandler(payload);
      } catch (error: any) {
        this.logger.warn("failed to handle message", error, payload);
      }

      return;
    }

    this.logger.warn("received invalid message", e);
  }

  private onOpen(e: Event) {
    this.logger.debug("event source connection opened", e);
  }

  public async connect() {
    if (this.isOpen) {
      this.logger.warn("channel connection already open");
      return;
    }

    this.isOpen = true;
    try {
      const token = await this.refreshToken();

      if (!token) return;

      const url = new URL("sse", this.sseBaseUrl);
      url.searchParams.append("v", "1.2");
      url.searchParams.append("accessToken", token);
      url.searchParams.append("channels", this.channel);
      url.searchParams.append("rewind", "1");

      this.eventSource = new EventSource(url);

      this.eventSource.addEventListener("error", (e) => this.onError(e));
      this.eventSource.addEventListener("open", (e) => this.onOpen(e));
      this.eventSource.addEventListener("message", (m) => this.onMessage(m));

      this.logger.debug("channel connection opened");
    } finally {
      this.isOpen = !!this.eventSource;
    }
  }

  public disconnect() {
    if (!this.isOpen) {
      this.logger.warn("channel connection already closed");
      return;
    }

    if (this.eventSource) {
      this.eventSource.close();
      this.eventSource = null;

      this.logger.debug("channel connection closed");
    }

    this.isOpen = false;
  }

  public open(options?: { retryInterval?: number; retryCount?: number }) {
    const retryInterval = options?.retryInterval ?? 1000 * 30;
    const retryCount = options?.retryCount ?? 3;
    let retriesRemaining = retryCount;

    const tryConnect = async () => {
      try {
        await this.connect();
        retriesRemaining = retryCount;
      } catch (e) {
        if (retriesRemaining > 0) {
          this.logger.warn(
            `failed to connect, ${retriesRemaining} retries remaining`,
            e,
          );
        } else {
          this.logger.warn(`failed to connect, no retries remaining`, e);
        }
      }
    };

    void tryConnect();

    this.retryInterval = setInterval(() => {
      if (!this.isConnected() && this.retryInterval) {
        if (retriesRemaining <= 0) {
          clearInterval(this.retryInterval);
          this.retryInterval = null;
          return;
        }

        retriesRemaining--;
        void tryConnect();
      }
    }, retryInterval);
  }

  public close() {
    if (this.retryInterval) {
      clearInterval(this.retryInterval);
      this.retryInterval = null;
    }

    this.disconnect();
  }

  public isActive() {
    return !!this.retryInterval;
  }

  public isConnected() {
    return this.isOpen && !!this.eventSource;
  }
}

export function openAblySSEChannel({
  userId,
  channel,
  callback,
  httpClient,
  sseBaseUrl,
  logger,
}: {
  userId: string;
  channel: string;
  callback: (req: object) => void;
  httpClient: HttpClient;
  logger: Logger;
  sseBaseUrl: string;
}) {
  const sse = new AblySSEChannel(
    userId,
    channel,
    sseBaseUrl,
    callback,
    httpClient,
    logger,
  );

  sse.open();

  return sse;
}

export function closeAblySSEChannel(channel: AblySSEChannel) {
  channel.close();
}

```

--------------------------------------------------------------------------------
/packages/browser-sdk/test/client.test.ts:
--------------------------------------------------------------------------------

```typescript
import { beforeEach, describe, expect, it, vi } from "vitest";

import { ReflagClient } from "../src/client";
import { FlagsClient } from "../src/flag/flags";
import { HttpClient } from "../src/httpClient";

import { flagsResult } from "./mocks/handlers";

describe("ReflagClient", () => {
  let client: ReflagClient;
  const httpClientPost = vi.spyOn(HttpClient.prototype as any, "post");
  const httpClientGet = vi.spyOn(HttpClient.prototype as any, "get");

  const flagClientSetContext = vi.spyOn(FlagsClient.prototype, "setContext");

  beforeEach(() => {
    client = new ReflagClient({
      publishableKey: "test-key",
      user: { id: "user1" },
      company: { id: "company1" },
    });

    vi.clearAllMocks();
  });

  describe("updateUser", () => {
    it("should update the user context", async () => {
      // and send new user data and trigger flag update
      const updatedUser = { name: "New User" };

      await client.updateUser(updatedUser);

      expect(client["context"].user).toEqual({ id: "user1", ...updatedUser });
      expect(httpClientPost).toHaveBeenCalledWith({
        path: "/user",
        body: {
          userId: "user1",
          attributes: { name: updatedUser.name },
        },
      });
      expect(flagClientSetContext).toHaveBeenCalledWith(client["context"]);
    });
  });

  describe("updateCompany", () => {
    it("should update the company context", async () => {
      // send new company data and trigger flag update
      const updatedCompany = { name: "New Company" };

      await client.updateCompany(updatedCompany);

      expect(client["context"].company).toEqual({
        id: "company1",
        ...updatedCompany,
      });
      expect(httpClientPost).toHaveBeenCalledWith({
        path: "/company",
        body: {
          userId: "user1",
          companyId: "company1",
          attributes: { name: updatedCompany.name },
        },
      });
      expect(flagClientSetContext).toHaveBeenCalledWith(client["context"]);
    });
  });

  describe("getFlag", () => {
    it("takes overrides into account", async () => {
      await client.initialize();
      expect(flagsResult["flagA"].isEnabled).toBe(true);
      expect(client.getFlag("flagA").isEnabled).toBe(true);
      client.getFlag("flagA").setIsEnabledOverride(false);
      expect(client.getFlag("flagA").isEnabled).toBe(false);
    });
  });

  describe("hooks integration", () => {
    it("on adds hooks appropriately, off removes them", async () => {
      const trackHook = vi.fn();
      const userHook = vi.fn();
      const companyHook = vi.fn();
      const checkHook = vi.fn();
      const flagsUpdated = vi.fn();

      client.on("track", trackHook);
      client.on("user", userHook);
      client.on("company", companyHook);
      client.on("check", checkHook);
      client.on("flagsUpdated", flagsUpdated);

      await client.track("test-event");
      expect(trackHook).toHaveBeenCalledWith({
        eventName: "test-event",
        attributes: undefined,
        user: client["context"].user,
        company: client["context"].company,
      });

      await client["user"]();
      expect(userHook).toHaveBeenCalledWith(client["context"].user);

      await client["company"]();
      expect(companyHook).toHaveBeenCalledWith(client["context"].company);

      // eslint-disable-next-line @typescript-eslint/no-unused-expressions -- special getter triggering event
      client.getFlag("flagA").isEnabled;
      expect(checkHook).toHaveBeenCalled();

      checkHook.mockReset();

      // eslint-disable-next-line @typescript-eslint/no-unused-expressions -- special getter triggering event
      client.getFlag("flagA").config;
      expect(checkHook).toHaveBeenCalled();

      expect(flagsUpdated).not.toHaveBeenCalled();
      await client.updateOtherContext({ key: "value" });
      expect(flagsUpdated).toHaveBeenCalled();

      // Remove hooks
      client.off("track", trackHook);
      client.off("user", userHook);
      client.off("company", companyHook);
      client.off("check", checkHook);
      client.off("flagsUpdated", flagsUpdated);

      // Reset mocks
      trackHook.mockReset();
      userHook.mockReset();
      companyHook.mockReset();
      checkHook.mockReset();
      flagsUpdated.mockReset();

      // Trigger events again
      await client.track("test-event");
      await client["user"]();
      await client["company"]();
      // eslint-disable-next-line @typescript-eslint/no-unused-expressions -- special getter triggering event
      client.getFlag("flagA").isEnabled;
      // eslint-disable-next-line @typescript-eslint/no-unused-expressions -- special getter triggering event
      client.getFlag("flagA").config;
      await client.updateOtherContext({ key: "value" });

      // Ensure hooks are not called
      expect(trackHook).not.toHaveBeenCalled();
      expect(userHook).not.toHaveBeenCalled();
      expect(companyHook).not.toHaveBeenCalled();
      expect(checkHook).not.toHaveBeenCalled();
      expect(flagsUpdated).not.toHaveBeenCalled();
    });
  });

  describe("offline mode", () => {
    it("should not make HTTP calls when offline", async () => {
      client = new ReflagClient({
        publishableKey: "test-key",
        user: { id: "user1" },
        company: { id: "company1" },
        offline: true,
        feedback: { enableAutoFeedback: true },
      });

      await client.initialize();
      await client.track("offline-event");
      await client.feedback({ flagKey: "flagA", score: 5 });
      await client.updateUser({ name: "New User" });
      await client.updateCompany({ name: "New Company" });
      await client.stop();

      expect(httpClientPost).not.toHaveBeenCalled();
      expect(httpClientGet).not.toHaveBeenCalled();
    });
  });

  describe("bootstrap parameter", () => {
    const flagsClientInitialize = vi.spyOn(FlagsClient.prototype, "initialize");

    beforeEach(() => {
      flagsClientInitialize.mockClear();
    });

    it("should use pre-fetched flags and skip initialization when flags are provided", async () => {
      const preFetchedFlags = {
        testFlag: {
          key: "testFlag",
          isEnabled: true,
          targetingVersion: 1,
        },
      };

      // Create a spy to monitor maybeFetchFlags which should not be called if already initialized
      const maybeFetchFlags = vi.spyOn(
        FlagsClient.prototype as any,
        "maybeFetchFlags",
      );

      client = new ReflagClient({
        publishableKey: "test-key",
        user: { id: "user1" },
        company: { id: "company1" },
        bootstrappedFlags: preFetchedFlags,
        feedback: { enableAutoFeedback: false }, // Disable to avoid HTTP calls
      });

      // FlagsClient should be bootstrapped but not initialized in constructor when flags are provided
      expect(client["flagsClient"]["bootstrapped"]).toBe(true);
      expect(client["flagsClient"]["initialized"]).toBe(false);
      expect(client.getFlags()).toEqual({
        testFlag: {
          key: "testFlag",
          isEnabled: true,
          targetingVersion: 1,
          isEnabledOverride: null,
        },
      });

      maybeFetchFlags.mockClear();

      await client.initialize();

      // After initialize, flagsClient should be properly initialized
      expect(client["flagsClient"]["initialized"]).toBe(true);

      // maybeFetchFlags should not be called since flagsClient is already bootstrapped
      expect(maybeFetchFlags).not.toHaveBeenCalled();
    });
  });
});

```

--------------------------------------------------------------------------------
/packages/react-sdk/dev/plain/app.tsx:
--------------------------------------------------------------------------------

```typescript
import React, { useState } from "react";

import {
  FlagKey,
  ReflagProvider,
  useFlag,
  useRequestFeedback,
  useTrack,
  useUpdateCompany,
  useUpdateOtherContext,
  useUpdateUser,
  useClient,
  ReflagBootstrappedProvider,
  RawFlags,
  useOnEvent,
} from "../../src";

// Extending the Flags interface to define the available features
declare module "../../src" {
  interface Flags {
    huddles: {
      config: {
        payload: {
          maxParticipants: number;
        };
      };
    };
  }
}

const publishableKey = import.meta.env.VITE_PUBLISHABLE_KEY || "";
const apiBaseUrl = import.meta.env.VITE_REFLAG_API_BASE_URL;

function HuddlesFeature() {
  // Type safe feature
  const feature = useFlag("huddles");
  return (
    <div>
      <h2>Huddles feature</h2>
      <pre>
        <code>{JSON.stringify(feature, null, 2)}</code>
      </pre>
    </div>
  );
}

// Initial context
const initialUser = {
  id: "demo-user",
  email: "[email protected]",
};
const initialCompany = {
  id: "demo-company",
  name: "Demo Company",
};
const initialOtherContext = {
  test: "test",
};

function UpdateContext() {
  const updateUser = useUpdateUser();
  const updateCompany = useUpdateCompany();
  const updateOtherContext = useUpdateOtherContext();

  const [newUser, setNewUser] = useState(JSON.stringify(initialUser));
  const [newCompany, setNewCompany] = useState(JSON.stringify(initialCompany));
  const [newOtherContext, setNewOtherContext] = useState(
    JSON.stringify(initialOtherContext),
  );

  return (
    <div>
      <h2>Update context</h2>
      <div>
        Update the context by editing the textarea. User/company IDs cannot be
        changed here.
      </div>
      <table>
        <tbody>
          <tr>
            <td>
              <textarea
                value={newCompany}
                onChange={(e) => setNewCompany(e.target.value)}
              ></textarea>
            </td>
            <td>
              <button onClick={() => updateCompany(JSON.parse(newCompany))}>
                Update company
              </button>
            </td>
          </tr>
          <tr>
            <td>
              <textarea
                value={newUser}
                onChange={(e) => setNewUser(e.target.value)}
              ></textarea>
            </td>
            <td>
              <button onClick={() => updateUser(JSON.parse(newUser))}>
                Update user
              </button>
            </td>
          </tr>
          <tr>
            <td>
              <textarea
                value={newOtherContext}
                onChange={(e) => setNewOtherContext(e.target.value)}
              ></textarea>
            </td>
            <td>
              <button
                onClick={() => updateOtherContext(JSON.parse(newOtherContext))}
              >
                Update other context
              </button>
            </td>
          </tr>
        </tbody>
      </table>
    </div>
  );
}

function SendEvent() {
  // Send track event
  const [eventName, setEventName] = useState("event1");
  const track = useTrack();
  return (
    <div>
      <h2>Send event</h2>
      <input
        onChange={(e) => setEventName(e.target.value)}
        type="text"
        placeholder="Event name"
        value={eventName}
      />
      <button
        onClick={() => {
          track(eventName);
        }}
      >
        Send event
      </button>
    </div>
  );
}

function Feedback() {
  const requestFeedback = useRequestFeedback();

  return (
    <div>
      <h2>Feedback</h2>
      <button
        onClick={(e) =>
          requestFeedback({
            title: "How do you like Huddles?",
            flagKey: "huddles",
            position: {
              type: "POPOVER",
              anchor: e.currentTarget as HTMLElement,
            },
          })
        }
      >
        Request feedback
      </button>
    </div>
  );
}

// App.tsx
function Demos() {
  return (
    <main>
      <h1>React SDK</h1>

      <HuddlesFeature />

      <h2>Feature opt-in</h2>
      <div>
        Create a <code>huddle</code> feature and set a rule:{" "}
        <code>optin-huddles IS TRUE</code>. Hit the checkbox below to opt-in/out
        of the feature.
      </div>
      <FeatureOptIn flagKey={"huddles"} featureName={"Huddles"} />

      <UpdateContext />
      <Feedback />
      <SendEvent />
      <CustomToolbar />
    </main>
  );
}

function FeatureOptIn({
  flagKey,
  featureName,
}: {
  flagKey: FlagKey;
  featureName: string;
}) {
  const updateUser = useUpdateUser();
  const [sendingUpdate, setSendingUpdate] = useState(false);
  const { isEnabled } = useFlag(flagKey);

  return (
    <div>
      <label htmlFor="huddlesOptIn">Opt-in to {featureName} feature</label>
      <input
        disabled={sendingUpdate}
        id="huddlesOptIn"
        type="checkbox"
        checked={isEnabled}
        onChange={() => {
          setSendingUpdate(true);
          updateUser({
            [`optin-${flagKey}`]: isEnabled ? "false" : "true",
          })?.then(() => {
            setSendingUpdate(false);
          });
        }}
      />
    </div>
  );
}

function CustomToolbar() {
  const client = useClient();
  const [flags, setFlags] = useState<RawFlags>(client.getFlags() ?? {});

  useOnEvent("flagsUpdated", () => {
    setFlags(client.getFlags() ?? {});
  });

  return (
    <div>
      <h2>Custom toolbar</h2>
      <p>This toolbar is static and won't update when flags are fetched.</p>
      <ul>
        {Object.entries(flags).map(([flagKey, feature]) => (
          <li key={flagKey}>
            {flagKey} -
            {(feature.isEnabledOverride ?? feature.isEnabled)
              ? "Enabled"
              : "Disabled"}{" "}
            {feature.isEnabledOverride !== null && (
              <button
                onClick={() => {
                  client.getFlag(flagKey).setIsEnabledOverride(null);
                }}
              >
                Reset
              </button>
            )}
            <input
              checked={feature.isEnabledOverride ?? feature.isEnabled}
              type="checkbox"
              onChange={(e) => {
                // this uses slightly simplified logic compared to the Reflag Toolbar
                client
                  .getFlag(flagKey)
                  .setIsEnabledOverride(e.target.checked ?? false);
              }}
            />
          </li>
        ))}
      </ul>
    </div>
  );
}

export function App() {
  const bootstrapped = new URLSearchParams(window.location.search).get(
    "bootstrapped",
  );

  if (bootstrapped) {
    return (
      <ReflagBootstrappedProvider
        publishableKey={publishableKey}
        flags={{
          context: {
            user: initialUser,
            company: initialCompany,
            other: initialOtherContext,
          },
          flags: {
            huddles: {
              key: "huddles",
              isEnabled: true,
            },
          },
        }}
        apiBaseUrl={apiBaseUrl}
      >
        {!publishableKey && (
          <div>
            No publishable key set. Please set the VITE_PUBLISHABLE_KEY
            environment variable.
          </div>
        )}
        <Demos />
      </ReflagBootstrappedProvider>
    );
  }

  return (
    <ReflagProvider
      publishableKey={publishableKey}
      context={{
        user: initialUser,
        company: initialCompany,
        other: initialOtherContext,
      }}
      apiBaseUrl={apiBaseUrl}
    >
      {!publishableKey && (
        <div>
          No publishable key set. Please set the VITE_PUBLISHABLE_KEY
          environment variable.
        </div>
      )}
      <Demos />
    </ReflagProvider>
  );
}

```

--------------------------------------------------------------------------------
/packages/cli/services/rules.ts:
--------------------------------------------------------------------------------

```typescript
export function getCursorRules() {
  return `
---
description: Guidelines for implementing flagging using Reflag feature management service
globs: "**/*.ts, **/*.tsx, **/*.js, **/*.jsx"
---

${rules}
`.trim();
}

export function getCopilotInstructions() {
  return rules;
}

const rules = /* markdown */ `
# Reflag Flag Management Service for LLMs

Reflag is a comprehensive feature management service offering flags, user feedback collection, adoption tracking, and remote configuration for your applications across various JavaScript frameworks, particularly React, Next.js, Node.js, vanilla browser, CLI, and OpenFeature environments. Follow these best practices for flagging.

## Follow Official Documentation

- Refer to [Reflag's official documentation](mdc:https:/docs.reflag.com) for implementation details.
- Adhere to Reflag's recommended patterns for each framework.

## Reflag SDK Usage

- Configure \`ReflagProvider\` or \`ReflagClient\` properly at application entry points.
- Leverage Reflag CLI for generating type-safe feature definitions.
- Write clean, type-safe code when applying Reflag flags.
- Follow established patterns in the project.

## Flag Implementation

- Create reusable hooks and utilities for consistent feature management.
- Write clear comments for usage and checks of a flag.
- Properly handle feature loading states to prevent UI flashing.
- Implement proper error fallbacks when flag services are unavailable.

## Flag Targeting

- Use release stages to manage feature rollout (for example, development, staging, production).
- Use targeting modes effectively:
  - \`none\`: Flag is disabled for all targets.
  - \`some\`: Flag is enabled only for specified targets.
  - \`everyone\`: Flag is enabled for all targets.
- Target features to specific users, companies, or segments.

## Analytics and Feedback

- Track feature usage with Reflag analytics.
- Collect user feedback on features.
- Monitor feature adoption and health.

## Common Concepts

### Targeting Rules

Targeting rules are entities used in Reflag to describe the target audience of a given feature. The target audience refers to the users who can interact with the feature within your application. Additionally, each targeting rule contains a value that is used for the target audience.

### Flag Stages

Release stages in Reflag allow setting up app-wide feature access targeting rules. Each release stage defines targeting rules for each available environment. Later, during the development of new features, you can apply all those rules automatically by selecting an available release stage.

Release stages are useful tools when a standard release workflow is used in your organization.

Predefined stages:

- In development
- Internal
- Beta
- General Availability

### Segments

A segment entity in Reflag is a dynamic collection of companies. Segments' dynamic nature results from the fact that they use filters to evaluate which companies are included in them.

#### Segment filters can be constructed using any combination of the following rules:

- company attributes
- user feature access
- feature metrics
- other segments

### Integrations

Connect Reflag with your existing tools:

- Linear
- Datadog
- Segment
- PostHog
- Amplitude
- Mixpanel
- AWS S3
- Slack

## React SDK Implementation

### Installation

\`\`\`bash
npm i @reflag/react-sdk
\`\`\`

### Key Features

- Flag toggling with fine-grained targeting
- User feedback collection
- Flag usage tracking
- Remote configuration
- Type-safe feature management

### Basic Setup

1. Add the \`ReflagProvider\` to wrap your application:

\`\`\`jsx
import { ReflagProvider } from "@reflag/react-sdk";

<ReflagProvider
  publishableKey="{YOUR_PUBLISHABLE_KEY}"
  company={{ id: "acme_inc", plan: "pro" }}
  user={{ id: "john_doe" }}
>
  <YourApp />
</ReflagProvider>;
\`\`\`

1. Create a feature and generate type-safe definitions:

\`\`\`bash
npm i --save-dev @reflag/cli
npx reflag new "Flag name"
\`\`\`

\`\`\`typescript
// DO NOT EDIT THIS FILE. IT IS GENERATED BY THE BUCKET CLI AND WILL BE OVERWRITTEN.
// eslint-disable
// prettier-ignore
import "@reflag/react-sdk";

declare module "@reflag/react-sdk" {
  export interface Flags {
    "flag-key": {
      config: {
        payload: {
          tokens: number;
        };
      };
    };
  }
}
\`\`\`

1. Use features in your components:

\`\`\`jsx
import { useFlag } from "@reflag/react-sdk";

function StartHuddleButton() {
  const {
    isLoading, // true while features are being loaded
    isEnabled, // boolean indicating if the feature is enabled
    config: {
      // feature configuration
      key, // string identifier for the config variant
      payload, // type-safe configuration object
    },
    track, // function to track feature usage
    requestFeedback, // function to request feedback for this feature
  } = useFlag("huddle");

  if (isLoading) {
    return <Loading />;
  }

  if (!isEnabled) {
    return null;
  }

  return (
    <>
      <button onClick={track}>Start huddle!</button>
      <button
        onClick={(e) =>
          requestFeedback({
            title: payload?.question ?? "How do you like the Huddles feature?",
            position: {
              type: "POPOVER",
              anchor: e.currentTarget as HTMLElement,
            },
          })
        }
      >
        Give feedback!
      </button>
    </>
  );
}
\`\`\`

### Core React Hooks

- \`useFlag()\` - Access feature status, config, and tracking
- \`useTrack()\` - Send custom events to Reflag
- \`useRequestFeedback()\` - Open feedback dialog for a feature
- \`useSendFeedback()\` - Programmatically send feedback
- \`useUpdateUser()\` / \`useUpdateCompany()\` - Update user/company data
- \`useUpdateOtherContext()\` - Update session-only context data
- \`useClient()\` - Access the underlying Reflag client

## Node.js SDK Implementation

### Installation

\`\`\`bash
npm i @reflag/node-sdk
\`\`\`

### Key Features

- Server-side flag evaluation
- User and company context management
- Flexible integration options
- Event tracking

### Basic Setup

\`\`\`javascript
import { ReflagClient } from "@reflag/node-sdk";

const client = new ReflagClient({
  secretKey: process.env.REFLAG_SECRET_KEY,
});

// Check if a feature is enabled
const isEnabled = await client.isEnabled("flag-key", {
  user: { id: "user_123", role: "admin" },
  company: { id: "company_456", plan: "enterprise" },
});
\`\`\`

### Context Management

\`\`\`javascript
// Set user and company context
await client.setContext({
  user: {
    id: "user_123",
    email: "[email protected]",
    role: "admin",
  },
  company: {
    id: "company_456",
    name: "Acme Inc",
    plan: "enterprise",
  },
});

// Check feature after setting context
const isEnabled = await client.isEnabled("flag-key");
\`\`\`

### Flag Configuration

\`\`\`javascript
// Get feature configuration
const config = await client.getConfig("flag-key", {
  user: { id: "user_123" },
  company: { id: "company_456" },
});

// Use the configuration
console.log(config.payload.maxDuration);
\`\`\`

### Event Tracking

\`\`\`javascript
// Track feature usage
await client.track("flag-key", {
  user: { id: "user_123" },
  company: { id: "company_456" },
  metadata: { action: "completed" },
});

// Track custom events
await client.trackEvent("custom-event", {
  user: { id: "user_123" },
  company: { id: "company_456" },
  metadata: { value: 42 },
});
\`\`\`

## Further Resources

- [Official Documentation](mdc:https:/docs.reflag.com)
- [Docs llms.txt](mdc:https:/docs.reflag.com/llms.txt)
- [GitHub Repository](mdc:https:/github.com/reflagcom/javascript)
- [Example React App](mdc:https:/github.com/reflagcom/javascript/tree/main/packages/react-sdk/dev)
`.trim();

```

--------------------------------------------------------------------------------
/packages/browser-sdk/test/promptStorage.test.ts:
--------------------------------------------------------------------------------

```typescript
import {
  afterAll,
  afterEach,
  beforeAll,
  describe,
  expect,
  test,
  vi,
} from "vitest";

import {
  checkPromptMessageCompleted,
  forgetAuthToken,
  getAuthToken,
  markPromptMessageCompleted,
  rememberAuthToken,
} from "../src/feedback/promptStorage";

describe("prompt-storage", () => {
  beforeAll(() => {
    const cookies: Record<string, string> = {};

    Object.defineProperty(document, "cookie", {
      set: (val: string) => {
        if (!val) {
          Object.keys(cookies).forEach((k) => delete cookies[k]);
          return;
        }
        const i = val.indexOf("=");
        cookies[val.slice(0, i)] = val.slice(i + 1);
      },
      get: () =>
        Object.entries(cookies)
          .map(([k, v]) => `${k}=${v}`)
          .join("; "),
    });

    vi.setSystemTime(new Date("2024-01-11T09:55:37.000Z"));
  });

  afterEach(() => {
    document.cookie = undefined!;
    vi.clearAllMocks();
  });

  afterAll(() => {
    vi.useRealTimers();
  });

  describe("markPromptMessageCompleted", () => {
    test("adds new cookie", async () => {
      markPromptMessageCompleted(
        "user",
        "prompt2",
        new Date("2024-01-04T14:01:20.000Z"),
      );

      expect(document.cookie).toBe(
        "reflag-prompt-user=prompt2; path=/; expires=Thu, 04 Jan 2024 14:01:20 GMT; sameSite=strict; secure",
      );
    });

    test("rewrites existing cookie", async () => {
      document.cookie =
        "reflag-prompt-user=prompt1; path=/; expires=Thu, 04 Jan 2021 14:01:20 GMT; sameSite=strict; secure";

      markPromptMessageCompleted(
        "user",
        "prompt2",
        new Date("2024-01-04T14:01:20.000Z"),
      );

      expect(document.cookie).toBe(
        "reflag-prompt-user=prompt2; path=/; expires=Thu, 04 Jan 2024 14:01:20 GMT; sameSite=strict; secure",
      );
    });
  });

  describe("checkPromptMessageCompleted", () => {
    test("cookie with same use and prompt results in true", async () => {
      document.cookie =
        "reflag-prompt-user=prompt; path=/; expires=Thu, 04 Jan 2024 14:01:20 GMT; sameSite=strict; secure";

      expect(checkPromptMessageCompleted("user", "prompt")).toBe(true);

      expect(document.cookie).toBe(
        "reflag-prompt-user=prompt; path=/; expires=Thu, 04 Jan 2024 14:01:20 GMT; sameSite=strict; secure",
      );
    });

    test("cookie with different prompt results in false", async () => {
      document.cookie =
        "reflag-prompt-user=prompt1; path=/; expires=Thu, 04 Jan 2024 14:01:20 GMT; sameSite=strict; secure";

      expect(checkPromptMessageCompleted("user", "prompt2")).toBe(false);
    });

    test("cookie with different user results in false", async () => {
      document.cookie =
        "reflag-prompt-user1=prompt1; path=/; expires=Thu, 04 Jan 2024 14:01:20 GMT; sameSite=strict; secure";

      expect(checkPromptMessageCompleted("user2", "prompt1")).toBe(false);
    });

    test("no cookie results in false", async () => {
      expect(checkPromptMessageCompleted("user", "prompt2")).toBe(false);
    });
  });

  describe("rememberAuthToken", () => {
    test("adds new cookie if none was there", async () => {
      expect(document.cookie).toBe("");

      rememberAuthToken(
        'user1"%%',
        "channel:suffix",
        "secret$%",
        new Date("2024-01-02T15:02:20.000Z"),
      );

      expect(document.cookie).toBe(
        "reflag-token-user1%22%25%25={%22channel%22:%22channel:suffix%22%2C%22token%22:%22secret$%25%22}; path=/; expires=Tue, 02 Jan 2024 15:02:20 GMT; sameSite=strict; secure",
      );
    });

    test("replaces existing cookie for same user", async () => {
      document.cookie =
        "reflag-token-user1%22%25%25={%22channel%22:%22channel:suffix%22%2C%22token%22:%22secret$%25%22}; path=/; expires=Tue, 02 Jan 2024 15:02:20 GMT; sameSite=strict; secure";

      rememberAuthToken(
        'user1"%%',
        "channel2:suffix2",
        "secret2$%",
        new Date("2023-01-02T15:02:20.000Z"),
      );

      expect(document.cookie).toBe(
        "reflag-token-user1%22%25%25={%22channel%22:%22channel2:suffix2%22%2C%22token%22:%22secret2$%25%22}; path=/; expires=Mon, 02 Jan 2023 15:02:20 GMT; sameSite=strict; secure",
      );
    });
  });

  describe("forgetAuthToken", () => {
    test("clears the user's cookie if even if there was nothing before", async () => {
      forgetAuthToken("user");

      expect(document.cookie).toBe(
        "reflag-token-user=; path=/; expires=Wed, 10 Jan 2024 09:55:37 GMT",
      );
    });

    test("clears the user's cookie", async () => {
      document.cookie =
        "reflag-token-user1={%22channel%22:%22channel:suffix%22%2C%22token%22:%22secret$%25%22}; path=/; expires=Tue, 02 Jan 2024 15:02:20 GMT; sameSite=strict; secure";

      forgetAuthToken("user1");

      expect(document.cookie).toBe(
        "reflag-token-user1=; path=/; expires=Wed, 10 Jan 2024 09:55:37 GMT",
      );
    });

    test("does nothing if there is a cookie for a different user", async () => {
      document.cookie =
        "reflag-token-user1={%22channel%22:%22channel:suffix%22%2C%22token%22:%22secret$%25%22}; path=/; expires=Tue, 02 Jan 2028 15:02:20 GMT; sameSite=strict; secure";

      forgetAuthToken("user2");

      expect(document.cookie).toBe(
        "reflag-token-user1={%22channel%22:%22channel:suffix%22%2C%22token%22:%22secret$%25%22}; path=/; expires=Tue, 02 Jan 2028 15:02:20 GMT; sameSite=strict; secure; reflag-token-user2=; path=/; expires=Wed, 10 Jan 2024 09:55:37 GMT",
      );
    });
  });

  describe("getAuthToken", () => {
    test("returns the auth token if it's available for the user", async () => {
      document.cookie =
        "reflag-token-user1%22%25%25={%22channel%22:%22channel:suffix%22%2C%22token%22:%22secret$%25%22}; path=/; expires=Tue, 02 Jan 2024 15:02:20 GMT; sameSite=strict; secure";

      expect(getAuthToken('user1"%%')).toStrictEqual({
        channel: "channel:suffix",
        token: "secret$%",
      });
    });

    test("return undefined if no cookie for user", async () => {
      document.cookie =
        "reflag-token-user1={%22channel%22:%22channel:suffix%22%2C%22token%22:%22secret$%25%22}; path=/; expires=Tue, 02 Jan 2024 15:02:20 GMT; sameSite=strict; secure";

      expect(getAuthToken("user2")).toBeUndefined();
    });

    test("returns undefined if no cookie", async () => {
      expect(getAuthToken("user")).toBeUndefined();
    });

    test("return undefined if corrupted cookie", async () => {
      document.cookie =
        "reflag-token-user={channel%22:%22channel:suffix%22%2C%22token%22:%22secret$%25%22}; path=/; expires=Tue, 02 Jan 2024 15:02:20 GMT; sameSite=strict; secure";

      expect(getAuthToken("user")).toBeUndefined();
    });

    test("return undefined if a field is missing", async () => {
      document.cookie =
        "reflag-token-user={%2C%22token%22:%22secret$%25%22}; path=/; expires=Tue, 02 Jan 2024 15:02:20 GMT; sameSite=strict; secure";

      expect(getAuthToken("user")).toBeUndefined();
    });
  });

  test("manages all cookies for the user", () => {
    rememberAuthToken(
      "user1",
      "channel:suffix",
      "secret$%",
      new Date("2024-01-02T15:02:20.000Z"),
    );

    markPromptMessageCompleted(
      "user1",
      "alex-prompt",
      new Date("2024-01-02T15:03:20.000Z"),
    );

    expect(document.cookie).toBe(
      "reflag-token-user1={%22channel%22:%22channel:suffix%22%2C%22token%22:%22secret$%25%22}; path=/; expires=Tue, 02 Jan 2024 15:02:20 GMT; sameSite=strict; secure; reflag-prompt-user1=alex-prompt; path=/; expires=Tue, 02 Jan 2024 15:03:20 GMT; sameSite=strict; secure",
    );

    forgetAuthToken("user1");

    expect(document.cookie).toBe(
      "reflag-token-user1=; path=/; expires=Wed, 10 Jan 2024 09:55:37 GMT; reflag-prompt-user1=alex-prompt; path=/; expires=Tue, 02 Jan 2024 15:03:20 GMT; sameSite=strict; secure",
    );
  });
});

```

--------------------------------------------------------------------------------
/packages/browser-sdk/src/feedback/ui/FeedbackForm.tsx:
--------------------------------------------------------------------------------

```typescript
import { FunctionComponent, h } from "preact";
import { useCallback, useEffect, useRef, useState } from "preact/hooks";

import { Check } from "../../ui/icons/Check";
import { CheckCircle } from "../../ui/icons/CheckCircle";

import { Button } from "./Button";
import { Plug } from "./Plug";
import { StarRating } from "./StarRating";
import {
  FeedbackScoreSubmission,
  FeedbackSubmission,
  FeedbackTranslations,
} from "./types";

const ANIMATION_SPEED = 400;

function getFeedbackDataFromForm(el: HTMLFormElement) {
  const formData = new FormData(el);
  return {
    score: Number(formData.get("score")?.toString()),
    comment: (formData.get("comment")?.toString() || "").trim(),
  };
}

type FeedbackFormProps = {
  t: FeedbackTranslations;
  question: string;
  scoreState: "idle" | "submitting" | "submitted";
  openWithCommentVisible: boolean;
  onInteraction: () => void;
  onSubmit: (
    data: Omit<FeedbackSubmission, "feebackId">,
  ) => Promise<void> | void;
  onScoreSubmit: (
    score: Omit<FeedbackScoreSubmission, "feebackId">,
  ) => Promise<void> | void;
};

export const FeedbackForm: FunctionComponent<FeedbackFormProps> = ({
  question,
  scoreState,
  openWithCommentVisible,
  onInteraction,
  onSubmit,
  onScoreSubmit,
  t,
}) => {
  const [hasRating, setHasRating] = useState(false);
  const [status, setStatus] = useState<"idle" | "submitting" | "submitted">(
    "idle",
  );
  const [error, setError] = useState<string>();
  const [showForm, setShowForm] = useState(true);

  const handleSubmit: h.JSX.GenericEventHandler<HTMLFormElement> = async (
    e,
  ) => {
    e.preventDefault();
    const data: FeedbackSubmission = {
      ...getFeedbackDataFromForm(e.target as HTMLFormElement),
      question,
    };
    if (!data.score) return;
    setError("");
    try {
      setStatus("submitting");
      await onSubmit(data);
      setStatus("submitted");
    } catch (err) {
      setStatus("idle");
      if (err instanceof Error) {
        setError(err.message);
      } else if (typeof err === "string") {
        setError(err);
      } else {
        setError("Couldn't submit feedback. Please try again.");
      }
    }
  };

  const containerRef = useRef<HTMLDivElement>(null);
  const formRef = useRef<HTMLFormElement>(null);
  const headerRef = useRef<HTMLDivElement>(null);
  const expandedContentRef = useRef<HTMLDivElement>(null);
  const submittedRef = useRef<HTMLDivElement>(null);

  const transitionToDefault = useCallback(() => {
    if (containerRef.current === null) return;
    if (headerRef.current === null) return;
    if (expandedContentRef.current === null) return;

    containerRef.current.style.maxHeight = `${headerRef.current.clientHeight}px`;

    expandedContentRef.current.style.position = "absolute";
    expandedContentRef.current.style.opacity = "0";
    expandedContentRef.current.style.pointerEvents = "none";
  }, [containerRef, headerRef, expandedContentRef]);

  const transitionToExpanded = useCallback(() => {
    if (containerRef.current === null) return;
    if (headerRef.current === null) return;
    if (expandedContentRef.current === null) return;

    containerRef.current.style.maxHeight = `${
      headerRef.current.clientHeight + // Header height
      expandedContentRef.current.clientHeight + // Comment + Button Height
      10 // Gap height
    }px`;

    expandedContentRef.current.style.position = "relative";
    expandedContentRef.current.style.opacity = "1";
    expandedContentRef.current.style.pointerEvents = "all";
  }, [containerRef, headerRef, expandedContentRef]);

  const transitionToSuccess = useCallback(() => {
    if (containerRef.current === null) return;
    if (formRef.current === null) return;
    if (submittedRef.current === null) return;

    formRef.current.style.opacity = "0";
    formRef.current.style.pointerEvents = "none";
    containerRef.current.style.maxHeight = `${submittedRef.current.clientHeight}px`;

    // Fade in "submitted" step once container has resized
    setTimeout(() => {
      submittedRef.current!.style.position = "relative";
      submittedRef.current!.style.opacity = "1";
      submittedRef.current!.style.pointerEvents = "all";
      setShowForm(false);
    }, ANIMATION_SPEED + 10);
  }, [formRef, containerRef, submittedRef]);

  useEffect(() => {
    if (status === "submitted") {
      transitionToSuccess();
    } else if (openWithCommentVisible || hasRating) {
      transitionToExpanded();
    } else {
      transitionToDefault();
    }
  }, [
    transitionToDefault,
    transitionToExpanded,
    transitionToSuccess,
    openWithCommentVisible,
    hasRating,
    status,
  ]);

  return (
    <div ref={containerRef} class="container">
      <div ref={submittedRef} class="submitted">
        <div class="submitted-check">
          <CheckCircle height={24} width={24} />
        </div>
        <p class="text">{t.SuccessMessage}</p>
        <Plug />
      </div>
      {showForm && (
        <form
          ref={formRef}
          class="form"
          method="dialog"
          style={{ opacity: 1 }}
          onClick={onInteraction}
          onFocus={onInteraction}
          onFocusCapture={onInteraction}
          onSubmit={handleSubmit}
        >
          <div
            ref={headerRef}
            aria-labelledby="reflag-feedback-score-label"
            class="form-control"
            role="group"
          >
            <div class="title" id="reflag-feedback-score-label">
              {question}
            </div>
            <StarRating
              name="score"
              t={t}
              onChange={async (e) => {
                setHasRating(true);
                await onScoreSubmit({
                  question,
                  score: Number(e.currentTarget.value),
                });
              }}
            />

            <ScoreStatus scoreState={scoreState} t={t} />
          </div>

          <div ref={expandedContentRef} class="form-expanded-content">
            <div class="form-control">
              <textarea
                class="textarea"
                id="reflag-feedback-comment-label"
                name="comment"
                placeholder={t.QuestionPlaceholder}
                rows={4}
              />
            </div>

            {error && <p class="error">{error}</p>}

            <Button
              disabled={
                !hasRating ||
                status === "submitting" ||
                scoreState === "submitting"
              }
              type="submit"
            >
              {t.SendButton}
            </Button>

            <Plug />
          </div>
        </form>
      )}
    </div>
  );
};

const ScoreStatus: FunctionComponent<{
  t: FeedbackTranslations;
  scoreState: "idle" | "submitting" | "submitted";
}> = ({ t, scoreState }) => {
  // Keep track of whether we can show a loading indication - only if 400ms have
  // elapsed without the score request finishing.
  const [loadingTimeElapsed, setLoadingTimeElapsed] = useState(false);

  // Keep track of whether we can fall back to the idle/loading states - once
  // it's been submit once it won't, to prevent flashing.
  const [hasBeenSubmitted, setHasBeenSubmitted] = useState(false);

  useEffect(() => {
    if (scoreState === "idle") {
      setLoadingTimeElapsed(false);
      return;
    }

    if (scoreState === "submitted") {
      setLoadingTimeElapsed(false);
      setHasBeenSubmitted(true);
      return;
    }

    const timer = setTimeout(() => {
      setLoadingTimeElapsed(true);
    }, 400);

    return () => clearTimeout(timer);
  }, [scoreState]);

  const showIdle =
    scoreState === "idle" ||
    (scoreState === "submitting" && !hasBeenSubmitted && !loadingTimeElapsed);
  const showLoading =
    scoreState !== "submitted" && !hasBeenSubmitted && loadingTimeElapsed;
  const showSubmitted = scoreState === "submitted" || hasBeenSubmitted;

  return (
    <div class="score-status-container">
      <span class="score-status" style={{ opacity: showIdle ? 1 : 0 }}>
        {t.ScoreStatusDescription}
      </span>

      <div class="score-status" style={{ opacity: showLoading ? 1 : 0 }}>
        {t.ScoreStatusLoading}
      </div>

      <span class="score-status" style={{ opacity: showSubmitted ? 1 : 0 }}>
        <Check height={14} style={{ marginRight: 3 }} width={14} />{" "}
        {t.ScoreStatusReceived}
      </span>
    </div>
  );
};

```

--------------------------------------------------------------------------------
/packages/node-sdk/test/utils.test.ts:
--------------------------------------------------------------------------------

```typescript
import { createHash } from "crypto";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import {
  decorateLogger,
  hashObject,
  isObject,
  mergeSkipUndefined,
  ok,
  once,
  TimeoutError,
  withTimeout,
} from "../src/utils";

describe("isObject", () => {
  it("should return true for an object", () => {
    expect(isObject({})).toBe(true);
  });

  it("should return false for an array", () => {
    expect(isObject([])).toBe(false);
  });

  it("should return false for a string", () => {
    expect(isObject("")).toBe(false);
  });

  it("should return false for a number", () => {
    expect(isObject(0)).toBe(false);
  });

  it("should return false for a boolean", () => {
    expect(isObject(true)).toBe(false);
  });

  it("should return false for null", () => {
    expect(isObject(null)).toBe(false);
  });

  it("should return false for undefined", () => {
    expect(isObject(undefined)).toBe(false);
  });
});

describe("ok", () => {
  it("should throw an error if the condition is false", () => {
    expect(() => ok(false, "error")).toThrowError("error");
  });

  it("should not throw an error if the condition is true", () => {
    expect(() => ok(true, "error")).not.toThrow();
  });
});

describe("decorateLogger", () => {
  it("should decorate the logger", () => {
    const logger = {
      debug: vi.fn(),
      info: vi.fn(),
      warn: vi.fn(),
      error: vi.fn(),
    };
    const decorated = decorateLogger("prefix", logger);

    decorated.debug("message");
    decorated.info("message");
    decorated.warn("message");
    decorated.error("message");

    expect(logger.debug).toHaveBeenCalledWith("prefix message");
    expect(logger.info).toHaveBeenCalledWith("prefix message");
    expect(logger.warn).toHaveBeenCalledWith("prefix message");
    expect(logger.error).toHaveBeenCalledWith("prefix message");
  });

  it("should throw an error if the prefix is not a string", () => {
    expect(() => decorateLogger(0 as any, {} as any)).toThrowError(
      "prefix must be a string",
    );
  });

  it("should throw an error if the logger is not an object", () => {
    expect(() => decorateLogger("", 0 as any)).toThrowError(
      "logger must be an object",
    );
  });
});

describe("mergeSkipUndefined", () => {
  it("merges two objects with no undefined values", () => {
    const target = { a: 1, b: 2 };
    const source = { b: 3, c: 4 };
    const result = mergeSkipUndefined(target, source);
    expect(result).toEqual({ a: 1, b: 3, c: 4 });
  });

  it("merges two objects where the source has undefined values", () => {
    const target = { a: 1, b: 2 };
    const source = { b: undefined, c: 4 };
    const result = mergeSkipUndefined(target, source);
    expect(result).toEqual({ a: 1, b: 2, c: 4 });
  });

  it("merges two objects where the target has undefined values", () => {
    const target = { a: 1, b: undefined };
    const source = { b: 3, c: 4 };
    const result = mergeSkipUndefined(target, source);
    expect(result).toEqual({ a: 1, b: 3, c: 4 });
  });

  it("merges two objects where both have undefined values", () => {
    const target = { a: 1, b: undefined };
    const source = { b: undefined, c: 4 };
    const result = mergeSkipUndefined(target, source);
    expect(result).toEqual({ a: 1, c: 4 });
  });

  it("merges two empty objects", () => {
    const target = {};
    const source = {};
    const result = mergeSkipUndefined(target, source);
    expect(result).toEqual({});
  });
});

describe("hashObject", () => {
  it("should throw if the given value is not an object", () => {
    expect(() => hashObject(null as any)).toThrowError(
      "validation failed: obj must be an object",
    );

    expect(() => hashObject("string" as any)).toThrowError(
      "validation failed: obj must be an object",
    );

    expect(() => hashObject([1, 2, 3] as any)).toThrowError(
      "validation failed: obj must be an object",
    );
  });

  it("should return consistent hash for same object content", () => {
    const obj = { name: "Alice", age: 30 };
    const hash1 = hashObject(obj);
    const hash2 = hashObject({ age: 30, name: "Alice" }); // different key order
    expect(hash1).toBe(hash2);
  });

  it("should return different hash for different objects", () => {
    const obj1 = { name: "Alice", age: 30 };
    const obj2 = { name: "Bob", age: 25 };
    const hash1 = hashObject(obj1);
    const hash2 = hashObject(obj2);
    expect(hash1).not.toBe(hash2);
  });

  it("should correctly hash nested objects", () => {
    const obj = { user: { name: "Alice", details: { age: 30, active: true } } };
    const hash = hashObject(obj);

    const expectedHash = createHash("sha1");
    expectedHash.update("user");
    expectedHash.update("details");
    expectedHash.update("active");
    expectedHash.update("true");
    expectedHash.update("age");
    expectedHash.update("30");
    expectedHash.update("name");
    expectedHash.update("Alice");

    expect(hash).toBe(expectedHash.digest("base64"));
  });

  it("should hash arrays within objects", () => {
    const obj = { numbers: [1, 2, 3] };
    const hash = hashObject(obj);

    const expectedHash = createHash("sha1");
    expectedHash.update("numbers");
    expectedHash.update("1");
    expectedHash.update("2");
    expectedHash.update("3");

    expect(hash).toBe(expectedHash.digest("base64"));
  });
});

describe("once()", () => {
  it("should call the function only once with void return value", () => {
    const fn = vi.fn();
    const onceFn = once(fn);

    onceFn();
    onceFn();
    onceFn();

    expect(fn).toHaveBeenCalledTimes(1);
  });

  it("should call the function only once", () => {
    const fn = vi.fn().mockReturnValue(1);
    const onceFn = once(fn);

    expect(onceFn()).toBe(1);
    expect(onceFn()).toBe(1);
    expect(onceFn()).toBe(1);

    expect(fn).toHaveBeenCalledTimes(1);
  });
});

describe("withTimeout()", () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  it("should resolve when promise completes before timeout", async () => {
    const promise = Promise.resolve("success");
    const result = withTimeout(promise, 1000);

    await expect(result).resolves.toBe("success");
  });

  it("should reject with TimeoutError when promise takes too long", async () => {
    const slowPromise = new Promise((resolve) => {
      setTimeout(() => resolve("too late"), 2000);
    });

    const result = withTimeout(slowPromise, 1000);

    vi.advanceTimersByTime(1000);

    await expect(result).rejects.toThrow("Operation timed out after 1000ms");
    await expect(result).rejects.toBeInstanceOf(TimeoutError);
  });

  it("should propagate original promise rejection", async () => {
    const error = new Error("original error");
    const failedPromise = Promise.reject(error);

    const result = withTimeout(failedPromise, 1000);

    await expect(result).rejects.toBe(error);
  });

  it("should reject immediately for negative timeout", async () => {
    const promise = Promise.resolve("success");

    await expect(async () => {
      await withTimeout(promise, -1);
    }).rejects.toThrow("validation failed: timeout must be a positive number");
  });

  it("should reject immediately for zero timeout", async () => {
    const promise = Promise.resolve("success");

    await expect(async () => {
      await withTimeout(promise, 0);
    }).rejects.toThrow("validation failed: timeout must be a positive number");
  });

  it("should clean up timeout when promise resolves", async () => {
    const clearTimeoutSpy = vi.spyOn(global, "clearTimeout");
    const promise = Promise.resolve("success");

    await withTimeout(promise, 1000);
    await vi.runAllTimersAsync();

    expect(clearTimeoutSpy).toHaveBeenCalled();
    clearTimeoutSpy.mockRestore();
  });

  it("should clean up timeout when promise rejects", async () => {
    const clearTimeoutSpy = vi.spyOn(global, "clearTimeout");
    const promise = Promise.reject(new Error("fail"));

    await expect(withTimeout(promise, 1000)).rejects.toThrow("fail");

    expect(clearTimeoutSpy).toHaveBeenCalled();
    clearTimeoutSpy.mockRestore();
  });

  it("should not resolve after timeout occurs", async () => {
    const slowPromise = new Promise((resolve) => {
      setTimeout(() => resolve("too late"), 2000);
    });

    const result = withTimeout(slowPromise, 1000);

    vi.advanceTimersByTime(1000); // Trigger timeout
    await expect(result).rejects.toThrow("Operation timed out after 1000ms");

    vi.advanceTimersByTime(1000); // Complete the original promise
    // The promise should still be rejected with the timeout error
    await expect(result).rejects.toThrow("Operation timed out after 1000ms");
  });
});

```

--------------------------------------------------------------------------------
/packages/cli/utils/auth.ts:
--------------------------------------------------------------------------------

```typescript
import crypto from "crypto";
import http from "http";
import chalk from "chalk";
import open from "open";

import { authStore } from "../stores/auth.js";
import { configStore } from "../stores/config.js";

import {
  CLIENT_VERSION_HEADER_NAME,
  CLIENT_VERSION_HEADER_VALUE,
  DEFAULT_AUTH_TIMEOUT,
} from "./constants.js";
import { ResponseError } from "./errors.js";
import { ParamType } from "./types.js";
import { errorUrl, successUrl } from "./urls.js";

const maxRetryCount = 1;

interface waitForAccessToken {
  accessToken: string;
  expiresAt: Date;
}

async function getOAuthServerUrls(apiUrl: string) {
  const { protocol, host } = new URL(apiUrl);
  const wellKnownUrl = `${protocol}//${host}/.well-known/oauth-authorization-server`;

  const response = await fetch(wellKnownUrl, {
    signal: AbortSignal.timeout(5000),
  });

  if (response.ok) {
    const data = (await response.json()) as {
      authorization_endpoint: string;
      token_endpoint: string;
      registration_endpoint: string;
      issuer: string;
    };

    return {
      registrationEndpoint:
        data.registration_endpoint ?? `${data.issuer}/oauth/register`,
      authorizationEndpoint: data.authorization_endpoint,
      tokenEndpoint: data.token_endpoint,
      issuer: data.issuer,
    };
  }

  throw new Error("Failed to fetch OAuth server metadata");
}

async function registerClient(
  registrationEndpoint: string,
  redirectUri: string,
) {
  const registrationResponse = await fetch(registrationEndpoint, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      client_name: "Reflag CLI",
      token_endpoint_auth_method: "none",
      grant_types: ["authorization_code"],
      redirect_uris: [redirectUri],
    }),
    signal: AbortSignal.timeout(5000),
  });

  if (!registrationResponse.ok) {
    throw new Error(`Could not register client with OAuth server`);
  }

  const registrationData = (await registrationResponse.json()) as {
    client_id: string;
  };

  return registrationData.client_id;
}

async function exchangeCodeForToken(
  tokenEndpoint: string,
  clientId: string,
  code: string,
  codeVerifier: string,
  redirectUri: string,
) {
  const response = await fetch(tokenEndpoint, {
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      client_id: clientId,
      code,
      code_verifier: codeVerifier,
      redirect_uri: redirectUri,
    }),
    signal: AbortSignal.timeout(5000),
  });

  if (!response.ok) {
    let errorDescription: string | undefined;

    try {
      const errorResponse = await response.json();
      errorDescription = errorResponse.error_description || errorResponse.error;
    } catch {
      // ignore
    }

    return { error: errorDescription ?? "unknown error" };
  }

  const successResponse = (await response.json()) as {
    access_token: string;
    expires_in: number;
  };

  return {
    accessToken: successResponse.access_token,
    expiresAt: new Date(Date.now() + successResponse.expires_in * 1000),
  };
}

function createChallenge() {
  // PKCE code verifier and challenge
  const codeVerifier = crypto.randomBytes(32).toString("base64url");
  const codeChallenge = crypto
    .createHash("sha256")
    .update(codeVerifier)
    .digest("base64")
    .replace(/=/g, "")
    .replace(/\+/g, "-")
    .replace(/\//g, "_");

  const state = crypto.randomUUID();

  return { codeVerifier, codeChallenge, state };
}

export async function waitForAccessToken(baseUrl: string, apiUrl: string) {
  const { authorizationEndpoint, tokenEndpoint, registrationEndpoint } =
    await getOAuthServerUrls(apiUrl);

  let resolve: (args: waitForAccessToken) => void;
  let reject: (arg0: Error) => void;

  const promise = new Promise<waitForAccessToken>((res, rej) => {
    resolve = res;
    reject = rej;
  });

  const { codeVerifier, codeChallenge, state } = createChallenge();

  const timeout = setTimeout(() => {
    cleanupAndReject(
      `authentication timed out after ${DEFAULT_AUTH_TIMEOUT / 1000} seconds`,
    );
  }, DEFAULT_AUTH_TIMEOUT);

  function cleanupAndReject(message: string) {
    cleanup();
    reject(new Error(`Could not authenticate: ${message}`));
  }

  function cleanup() {
    clearTimeout(timeout);
    server.close();
    server.closeAllConnections();
  }

  const server = http.createServer();

  server.listen();

  const address = server.address();
  if (address == null || typeof address !== "object") {
    throw new Error("Could not start server");
  }

  const callbackPath = "/oauth_callback";
  const redirectUri = `http://localhost:${address.port}${callbackPath}`;

  const clientId = await registerClient(registrationEndpoint, redirectUri);

  const params = {
    response_type: "code",
    client_id: clientId,
    redirect_uri: redirectUri,
    state,
    code_challenge: codeChallenge,
    code_challenge_method: "S256",
  };

  const browserUrl = `${authorizationEndpoint}?${new URLSearchParams(params).toString()}`;

  server.on("request", async (req, res) => {
    if (!clientId || !redirectUri) {
      res.writeHead(500).end("Something went wrong");

      cleanupAndReject("something went wrong");
      return;
    }

    const url = new URL(req.url ?? "/", "http://127.0.0.1");

    if (url.pathname !== callbackPath) {
      res.writeHead(404).end("Invalid path");

      cleanupAndReject("invalid path");
      return;
    }

    const error = url.searchParams.get("error");
    if (error) {
      res.writeHead(400).end("Could not authenticate");

      const errorDescription = url.searchParams.get("error_description");
      cleanupAndReject(`${errorDescription || error} `);
      return;
    }

    const code = url.searchParams.get("code");
    if (!code) {
      res.writeHead(400).end("Could not authenticate");

      cleanupAndReject("no code provided");
      return;
    }

    const response = await exchangeCodeForToken(
      tokenEndpoint,
      clientId,
      code,
      codeVerifier,
      redirectUri,
    );

    if ("error" in response) {
      res
        .writeHead(302, {
          location: errorUrl(
            baseUrl,
            "Could not authenticate: unable to fetch access token",
          ),
        })
        .end("Could not authenticate");

      cleanupAndReject(JSON.stringify(response.error));
      return;
    }

    res
      .writeHead(302, {
        location: successUrl(baseUrl),
      })
      .end("Authentication successful");

    cleanup();
    resolve(response);
  });

  console.log(
    `Opened web browser to facilitate login: ${chalk.cyan(browserUrl)}`,
  );

  void open(browserUrl);

  return promise;
}

export async function authRequest<T = Record<string, unknown>>(
  url: string,
  options?: RequestInit & {
    params?: Record<string, ParamType | ParamType[] | null | undefined>;
  },
  retryCount = 0,
): Promise<T> {
  const { baseUrl, apiUrl } = configStore.getConfig();
  const { token, isApiKey } = authStore.getToken(baseUrl);

  if (!token) {
    const accessToken = await waitForAccessToken(baseUrl, apiUrl);

    await authStore.setToken(baseUrl, accessToken.accessToken);
    return authRequest(url, options);
  }

  if (url.startsWith("/")) {
    url = url.slice(1);
  }

  const resolvedUrl = new URL(`${apiUrl}/${url}`);

  if (options?.params) {
    Object.entries(options.params).forEach(([key, value]) => {
      if (value !== null && value !== undefined) {
        if (Array.isArray(value)) {
          value.forEach((v) => resolvedUrl.searchParams.append(key, String(v)));
        } else {
          resolvedUrl.searchParams.set(key, String(value));
        }
      }
    });
  }

  let response: Response | undefined;

  try {
    response = await fetch(resolvedUrl, {
      ...options,
      headers: {
        ...options?.headers,
        Authorization: `Bearer ${token}`,
        [CLIENT_VERSION_HEADER_NAME]: CLIENT_VERSION_HEADER_VALUE(
          configStore.getClientVersion() ?? "unknown",
        ),
      },
    });
  } catch (error: unknown) {
    const message =
      error && typeof error == "object" && "message" in error
        ? error.message
        : "unknown";

    throw new Error(`Failed to connect to "${resolvedUrl}". Error: ${message}`);
  }

  if (!response.ok) {
    if (response.status === 401) {
      if (isApiKey) {
        throw new Error(
          `The provided API key is not valid for "${resolvedUrl}".`,
        );
      }

      await authStore.setToken(baseUrl, null);

      if (retryCount < maxRetryCount) {
        return authRequest(url, options, retryCount + 1);
      }
    }

    const data = await response.json();
    throw new ResponseError(data);
  }

  return response.json();
}

```

--------------------------------------------------------------------------------
/packages/vue-sdk/src/hooks.ts:
--------------------------------------------------------------------------------

```typescript
import {
  computed,
  inject,
  InjectionKey,
  onMounted,
  onUnmounted,
  ref,
} from "vue";

import {
  HookArgs,
  InitOptions,
  ReflagClient,
  RequestFeedbackData,
  UnassignedFeedback,
} from "@reflag/browser-sdk";

import {
  FlagKey,
  ProviderContextType,
  RequestFlagFeedbackOptions,
  TypedFlags,
} from "./types";
import { SDK_VERSION } from "./version";

export const ProviderSymbol: InjectionKey<ProviderContextType> =
  Symbol("ReflagProvider");

/**
 * Map of clients by context key. Used to deduplicate initialization of the client.
 * @internal
 */
const reflagClients = new Map<string, ReflagClient>();

/**
 * Returns the ReflagClient for a given publishable key.
 * Only creates a new ReflagClient if not already created or if it hook is run on the server.
 * @internal
 */
export function useReflagClient(
  initOptions: InitOptions,
  debug = false,
): ReflagClient {
  const isServer = typeof window === "undefined";
  if (isServer || !reflagClients.has(initOptions.publishableKey)) {
    const client = new ReflagClient({
      ...initOptions,
      sdkVersion: SDK_VERSION,
      logger: debug ? console : undefined,
    });
    if (!isServer) {
      reflagClients.set(initOptions.publishableKey, client);
    }
    return client;
  }
  return reflagClients.get(initOptions.publishableKey)!;
}

/**
 * Vue composable for getting the state of a given flag for the current context.
 *
 * This composable returns an object with the state of the flag for the current context.
 *
 * @param key - The key of the flag to get the state of.
 * @returns An object with the state of the flag.
 *
 * @example
 * ```ts
 * import { useFlag } from '@reflag/vue-sdk';
 *
 * const { isEnabled, config, track, requestFeedback } = useFlag("huddles");
 *
 * function StartHuddlesButton() {
 *   const { isEnabled, config: { payload }, track } = useFlag("huddles");
 *   if (isEnabled) {
 *    return <button onClick={() => track()}>{payload?.buttonTitle ?? "Start Huddles"}</button>;
 * }
 * ```
 */
export function useFlag<TKey extends FlagKey>(key: TKey): TypedFlags[TKey] {
  const client = useClient();
  const isLoading = useIsLoading();

  const track = () => client.track(key);
  const requestFeedback = (opts: RequestFlagFeedbackOptions) =>
    client.requestFeedback({ ...opts, flagKey: key });

  const flag = ref(
    client.getFlag(key) || {
      isEnabled: false,
      config: { key: undefined, payload: undefined },
    },
  );

  const updateFlag = () => {
    flag.value = client.getFlag(key);
  };

  onMounted(() => {
    updateFlag();
  });

  useOnEvent("flagsUpdated", updateFlag);

  return {
    key,
    isLoading,
    isEnabled: computed(() => flag.value.isEnabled),
    config: computed(() => flag.value.config),
    track,
    requestFeedback,
  } as TypedFlags[TKey];
}

/**
 * Vue composable for tracking custom events.
 *
 * This composable returns a function that can be used to track custom events
 * with the Reflag SDK.
 *
 * @example
 * ```ts
 * import { useTrack } from '@reflag/vue-sdk';
 *
 * const track = useTrack();
 *
 * // Track a custom event
 * track('button_clicked', { buttonName: 'Start Huddles' });
 * ```
 *
 * @returns A function that tracks an event. The function accepts:
 *   - `eventName`: The name of the event to track.
 *   - `attributes`: (Optional) Additional attributes to associate with the event.
 */
export function useTrack() {
  const client = useClient();
  return (eventName: string, attributes?: Record<string, any> | null) =>
    client.track(eventName, attributes);
}

/**
 * Vue composable for requesting user feedback.
 *
 * This composable returns a function that can be used to trigger the feedback
 * collection flow with the Reflag SDK. You can use this to prompt users for
 * feedback at any point in your application.
 *
 * @example
 * ```ts
 * import { useRequestFeedback } from '@reflag/vue-sdk';
 *
 * const requestFeedback = useRequestFeedback();
 *
 * // Request feedback from the user
 * requestFeedback({
 *   prompt: "How was your experience?",
 *   metadata: { page: "dashboard" }
 * });
 * ```
 *
 * @returns A function that requests feedback from the user. The function accepts:
 *   - `options`: An object containing feedback request options.
 */
export function useRequestFeedback() {
  const client = useClient();
  return (options: RequestFeedbackData) => client.requestFeedback(options);
}

/**
 * Vue composable for sending feedback.
 *
 * This composable returns a function that can be used to send feedback to the
 * Reflag SDK. You can use this to send feedback from your application.
 *
 * @example
 * ```ts
 * import { useSendFeedback } from '@reflag/vue-sdk';
 *
 * const sendFeedback = useSendFeedback();
 *
 * // Send feedback from the user
 * sendFeedback({
 *   feedback: "I love this flag!",
 *   metadata: { page: "dashboard" }
 * });
 * ```
 *
 * @returns A function that sends feedback to the Reflag SDK. The function accepts:
 *   - `options`: An object containing feedback options.
 */
export function useSendFeedback() {
  const client = useClient();
  return (opts: UnassignedFeedback) => client.feedback(opts);
}

/**
 * Vue composable for updating the user context.
 *
 * This composable returns a function that can be used to update the user context
 * with the Reflag SDK. You can use this to update the user context at any point
 * in your application.
 *
 * @example
 * ```ts
 * import { useUpdateUser } from '@reflag/vue-sdk';
 *
 * const updateUser = useUpdateUser();
 *
 * // Update the user context
 * updateUser({ id: "123", name: "John Doe" });
 * ```
 *
 * @returns A function that updates the user context. The function accepts:
 *   - `opts`: An object containing the user context to update.
 */
export function useUpdateUser() {
  const client = useClient();
  return (opts: { [key: string]: string | number | undefined }) =>
    client.updateUser(opts);
}

/**
 * Vue composable for updating the company context.
 *
 * This composable returns a function that can be used to update the company
 * context with the Reflag SDK. You can use this to update the company context
 * at any point in your application.
 *
 * @example
 * ```ts
 * import { useUpdateCompany } from '@reflag/vue-sdk';
 *
 * const updateCompany = useUpdateCompany();
 *
 * // Update the company context
 * updateCompany({ id: "123", name: "Acme Inc." });
 * ```
 *
 * @returns A function that updates the company context. The function accepts:
 *   - `opts`: An object containing the company context to update.
 */
export function useUpdateCompany() {
  const client = useClient();
  return (opts: { [key: string]: string | number | undefined }) =>
    client.updateCompany(opts);
}

/**
 * Vue composable for updating the other context.
 *
 * This composable returns a function that can be used to update the other
 * context with the Reflag SDK. You can use this to update the other context
 * at any point in your application.
 *
 * @example
 * ```ts
 * import { useUpdateOtherContext } from '@reflag/vue-sdk';
 *
 * const updateOtherContext = useUpdateOtherContext();
 *
 * // Update the other context
 * updateOtherContext({ id: "123", name: "Acme Inc." });
 * ```
 *
 * @returns A function that updates the other context. The function accepts:
 *   - `opts`: An object containing the other context to update.
 */
export function useUpdateOtherContext() {
  const client = useClient();
  return (opts: { [key: string]: string | number | undefined }) =>
    client.updateOtherContext(opts);
}

/**
 * Vue composable for getting the Reflag client.
 *
 * This composable returns the Reflag client. You can use this to get the Reflag
 * client at any point in your application.
 *
 * @example
 * ```ts
 * import { useClient } from '@reflag/vue-sdk';
 *
 * const client = useClient();
 *
 * console.log(client.getContext());
 * ```
 * @returns The Reflag client.
 */
export function useClient() {
  const ctx = injectSafe();
  return ctx.client;
}

/**
 * Vue composable for checking if the Reflag client is loading.
 *
 * This composable returns a boolean value that indicates whether the Reflag client is loading.
 * You can use this to check if the Reflag client is loading at any point in your application.
 * Initially, the value will be true until the client is initialized.
 *
 * @example
 * ```ts
 * import { useIsLoading } from '@reflag/vue-sdk';
 *
 * const isLoading = useIsLoading();
 *
 * console.log(isLoading);
 * ```
 */
export function useIsLoading() {
  const ctx = injectSafe();
  return ctx.isLoading;
}

/**
 * Vue composable for listening to Reflag client events.
 *
 * @example
 * ```ts
 * import { useOnEvent } from '@reflag/vue-sdk';
 *
 * useOnEvent("flagsUpdated", () => {
 *   console.log("flags updated");
 * });
 * ```
 *
 * @param event - The event to listen to.
 * @param handler - The function to call when the event is triggered.
 * @param client - The Reflag client to listen to. If not provided, the client will be retrieved from the context.
 */
export function useOnEvent<THookType extends keyof HookArgs>(
  event: THookType,
  handler: (arg0: HookArgs[THookType]) => void,
  client?: ReflagClient,
) {
  const resolvedClient = client ?? useClient();
  let off: () => void;
  onMounted(() => {
    off = resolvedClient.on(event, handler);
  });
  onUnmounted(() => {
    off();
  });
}

function injectSafe() {
  const ctx = inject(ProviderSymbol);
  if (!ctx) {
    throw new Error(
      `ReflagProvider is missing. Please ensure your component is wrapped with a ReflagProvider.`,
    );
  }
  return ctx;
}

```

--------------------------------------------------------------------------------
/packages/openfeature-browser-provider/src/index.test.ts:
--------------------------------------------------------------------------------

```typescript
import { Client, OpenFeature } from "@openfeature/web-sdk";
import { beforeEach, describe, expect, it, Mock, vi } from "vitest";

import { ReflagClient } from "@reflag/browser-sdk";

import { defaultContextTranslator, ReflagBrowserSDKProvider } from ".";

vi.mock("@reflag/browser-sdk", () => {
  const actualModule = vi.importActual("@reflag/browser-sdk");

  return {
    __esModule: true,
    ...actualModule,
    ReflagClient: vi.fn(),
  };
});

const testFlagKey = "a-key";

const publishableKey = "your-publishable-key";

describe("ReflagBrowserSDKProvider", () => {
  let provider: ReflagBrowserSDKProvider;
  let ofClient: Client;
  const reflagClientMock = {
    getFlags: vi.fn(),
    getFlag: vi.fn(),
    initialize: vi.fn().mockResolvedValue({}),
    track: vi.fn(),
    stop: vi.fn(),
  };

  const mockReflagClient = ReflagClient as Mock;
  mockReflagClient.mockReturnValue(reflagClientMock);

  beforeEach(async () => {
    await OpenFeature.clearProviders();

    provider = new ReflagBrowserSDKProvider({ publishableKey });
    OpenFeature.setProvider(provider);
    ofClient = OpenFeature.getClient();
  });

  beforeEach(() => {
    vi.clearAllMocks();
  });

  const contextTranslatorFn = vi.fn();

  describe("lifecycle", () => {
    it("should call initialize function with correct arguments", async () => {
      await provider.initialize();
      expect(ReflagClient).toHaveBeenCalledTimes(1);
      expect(ReflagClient).toHaveBeenCalledWith({
        publishableKey,
      });
      expect(reflagClientMock.initialize).toHaveBeenCalledTimes(1);
    });

    it("should set the status to READY if initialization succeeds", async () => {
      reflagClientMock.initialize.mockReturnValue(Promise.resolve());
      await provider.initialize();
      expect(reflagClientMock.initialize).toHaveBeenCalledTimes(1);
      expect(provider.status).toBe("READY");
    });

    it("should call stop function when provider is closed", async () => {
      await OpenFeature.clearProviders();
      expect(reflagClientMock.stop).toHaveBeenCalledTimes(1);
    });

    it("onContextChange re-initializes client", async () => {
      const p = new ReflagBrowserSDKProvider({ publishableKey });
      expect(p["_client"]).toBeUndefined();
      expect(mockReflagClient).toHaveBeenCalledTimes(0);

      await p.onContextChange({}, {});
      expect(mockReflagClient).toHaveBeenCalledTimes(1);
      expect(p["_client"]).toBeDefined();
    });
  });

  describe("contextTranslator", () => {
    it("uses contextTranslatorFn if provided", async () => {
      const ofContext = {
        userId: "123",
        email: "[email protected]",
        avatar: "https://reflag.com/avatar.png",
        groupId: "456",
        groupName: "reflag",
        groupAvatar: "https://reflag.com/group-avatar.png",
        groupPlan: "pro",
      };

      const reflagContext = {
        user: {
          id: "123",
          name: "John Doe",
          email: "[email protected]",
          avatar: "https://acme.com/avatar.png",
        },
        company: {
          id: "456",
          name: "Acme, Inc.",
          plan: "pro",
          avatar: "https://acme.com/company-avatar.png",
        },
      };

      contextTranslatorFn.mockReturnValue(reflagContext);
      provider = new ReflagBrowserSDKProvider({
        publishableKey,
        contextTranslator: contextTranslatorFn,
      });

      await provider.initialize(ofContext);

      expect(contextTranslatorFn).toHaveBeenCalledWith(ofContext);
      expect(mockReflagClient).toHaveBeenCalledWith({
        publishableKey,
        ...reflagContext,
      });
    });

    it("defaultContextTranslator provides the correct context", async () => {
      expect(
        defaultContextTranslator({
          userId: 123,
          name: "John Doe",
          email: "[email protected]",
          avatar: "https://reflag.com/avatar.png",
          companyId: "456",
          companyName: "Acme, Inc.",
          companyAvatar: "https://acme.com/company-avatar.png",
          companyPlan: "pro",
        }),
      ).toEqual({
        user: {
          id: "123",
          name: "John Doe",
          email: "[email protected]",
          avatar: "https://reflag.com/avatar.png",
        },
        company: {
          id: "456",
          name: "Acme, Inc.",
          plan: "pro",
          avatar: "https://acme.com/company-avatar.png",
        },
      });
    });

    it("defaultContextTranslator uses targetingKey if provided", async () => {
      expect(
        defaultContextTranslator({
          targetingKey: "123",
        }),
      ).toMatchObject({
        user: {
          id: "123",
        },
        company: {
          id: undefined,
        },
      });
    });
  });

  describe("resolving flags", () => {
    beforeEach(async () => {
      await provider.initialize();
    });

    function mockFlag(
      enabled: boolean,
      configKey?: string | null,
      configPayload?: any,
    ) {
      const config = {
        key: configKey,
        payload: configPayload,
      };

      reflagClientMock.getFlag = vi.fn().mockReturnValue({
        isEnabled: enabled,
        config,
      });

      reflagClientMock.getFlags = vi.fn().mockReturnValue({
        [testFlagKey]: {
          isEnabled: enabled,
          config: {
            key: "key",
            payload: configPayload,
          },
        },
      });
    }

    it("returns error if provider is not initialized", async () => {
      await OpenFeature.clearProviders();

      const val = ofClient.getBooleanDetails(testFlagKey, true);

      expect(val).toMatchObject({
        flagKey: testFlagKey,
        flagMetadata: {},
        reason: "ERROR",
        errorCode: "PROVIDER_NOT_READY",
        value: true,
      });
    });

    it("returns error if flag is not found", async () => {
      mockFlag(true, "key", true);
      const val = ofClient.getBooleanDetails("missing-key", true);

      expect(val).toMatchObject({
        flagKey: "missing-key",
        flagMetadata: {},
        reason: "ERROR",
        errorCode: "FLAG_NOT_FOUND",
        value: true,
      });
    });

    it("calls the client correctly when evaluating", async () => {
      mockFlag(true, "key", true);

      const val = ofClient.getBooleanDetails(testFlagKey, false);

      expect(val).toMatchObject({
        flagKey: testFlagKey,
        flagMetadata: {},
        reason: "TARGETING_MATCH",
        variant: "key",
        value: true,
      });

      expect(reflagClientMock.getFlags).toHaveBeenCalled();
      expect(reflagClientMock.getFlag).toHaveBeenCalledWith(testFlagKey);
    });

    it.each([
      [true, false, true, "TARGETING_MATCH", undefined],
      [undefined, true, true, "ERROR", "FLAG_NOT_FOUND"],
      [undefined, false, false, "ERROR", "FLAG_NOT_FOUND"],
    ])(
      "should return the correct result when evaluating boolean. enabled: %s, value: %s, default: %s, expected: %s, reason: %s, errorCode: %s`",
      (enabled, def, expected, reason, errorCode) => {
        const configKey = enabled !== undefined ? "variant-1" : undefined;
        const flagKey = enabled ? testFlagKey : "missing-key";

        mockFlag(enabled ?? false, configKey);

        expect(ofClient.getBooleanDetails(flagKey, def)).toMatchObject({
          flagKey,
          flagMetadata: {},
          reason,
          value: expected,
          ...(errorCode ? { errorCode } : {}),
          ...(configKey ? { variant: configKey } : {}),
        });
      },
    );

    it("should return error when evaluating number", async () => {
      expect(ofClient.getNumberDetails(testFlagKey, 1)).toMatchObject({
        flagKey: testFlagKey,
        flagMetadata: {},
        reason: "ERROR",
        errorCode: "GENERAL",
        value: 1,
      });
    });

    it.each([
      ["key-1", "default", "key-1", "TARGETING_MATCH"],
      [null, "default", "default", "DEFAULT"],
      [undefined, "default", "default", "DEFAULT"],
    ])(
      "should return the correct result when evaluating string. variant: %s, def: %s, expected: %s, reason: %s, errorCode: %s`",
      (variant, def, expected, reason) => {
        mockFlag(true, variant, {});
        expect(ofClient.getStringDetails(testFlagKey, def)).toMatchObject({
          flagKey: testFlagKey,
          flagMetadata: {},
          reason,
          value: expected,
          ...(variant ? { variant } : {}),
        });
      },
    );

    it.each([
      ["one", {}, { a: 1 }, {}, "TARGETING_MATCH", undefined],
      ["two", "string", "default", "string", "TARGETING_MATCH", undefined],
      ["three", 15, 16, 15, "TARGETING_MATCH", undefined],
      ["four", true, true, true, "TARGETING_MATCH", undefined],
      ["five", 100, "string", "string", "ERROR", "TYPE_MISMATCH"],
      ["six", 1337, true, true, "ERROR", "TYPE_MISMATCH"],
      ["seven", "string", 1337, 1337, "ERROR", "TYPE_MISMATCH"],
      [undefined, null, { a: 2 }, { a: 2 }, "ERROR", "TYPE_MISMATCH"],
      [undefined, undefined, "a", "a", "ERROR", "TYPE_MISMATCH"],
    ])(
      "should return the correct result when evaluating object. variant: %s, value: %s, default: %s, expected: %s, reason: %s, errorCode: %s`",
      (variant, value, def, expected, reason, errorCode) => {
        mockFlag(true, variant, value);

        expect(ofClient.getObjectDetails(testFlagKey, def)).toMatchObject({
          flagKey: testFlagKey,
          flagMetadata: {},
          reason,
          value: expected,
          ...(errorCode ? { errorCode } : {}),
          ...(variant && !errorCode ? { variant } : {}),
        });
      },
    );
  });

  describe("track", () => {
    it("calls the client correctly for track calls", async () => {
      const testEvent = "testEvent";
      await provider.initialize();

      ofClient.track(testEvent, { key: "value" });
      expect(reflagClientMock.track).toHaveBeenCalled();
      expect(reflagClientMock.track).toHaveBeenCalledWith(testEvent, {
        key: "value",
      });
    });
  });
});

```

--------------------------------------------------------------------------------
/packages/openfeature-node-provider/src/index.test.ts:
--------------------------------------------------------------------------------

```typescript
import { ProviderStatus } from "@openfeature/server-sdk";
import { beforeEach, describe, expect, it, Mock, vi } from "vitest";

import { ReflagClient } from "@reflag/node-sdk";

import { defaultContextTranslator, ReflagNodeProvider } from "./index";

vi.mock("@reflag/node-sdk", () => {
  const actualModule = vi.importActual("@reflag/node-sdk");

  return {
    __esModule: true,
    ...actualModule,
    ReflagClient: vi.fn(),
  };
});

const reflagClientMock = {
  getFlag: vi.fn(),
  getFlagDefinitions: vi.fn().mockReturnValue([]),
  initialize: vi.fn().mockResolvedValue({}),
  flush: vi.fn(),
  track: vi.fn(),
};

const secretKey = "sec_fakeSecretKey______"; // must be 23 characters long

const context = {
  targetingKey: "abc",
  name: "John Doe",
  email: "[email protected]",
};

const reflagContext = {
  user: { id: "42" },
  company: { id: "99" },
};

const testFlagKey = "a-key";

beforeEach(() => {
  vi.clearAllMocks();
});

describe("ReflagNodeProvider", () => {
  let provider: ReflagNodeProvider;

  const mockReflagClient = ReflagClient as Mock;
  mockReflagClient.mockReturnValue(reflagClientMock);

  let mockTranslatorFn: Mock;

  function mockFlag(
    enabled: boolean,
    configKey?: string | null,
    configPayload?: any,
    flagKey = testFlagKey,
  ) {
    const config = {
      key: configKey,
      payload: configPayload,
    };

    reflagClientMock.getFlag = vi.fn().mockReturnValue({
      isEnabled: enabled,
      config,
    });

    // Mock getFlagDefinitions to return feature definitions that include the specified flag
    reflagClientMock.getFlagDefinitions = vi.fn().mockReturnValue([
      {
        key: flagKey,
        description: "Test flag",
        flag: {},
        config: {},
      },
    ]);
  }

  beforeEach(async () => {
    mockTranslatorFn = vi.fn().mockReturnValue(reflagContext);

    provider = new ReflagNodeProvider({
      secretKey,
      contextTranslator: mockTranslatorFn,
    });

    await provider.initialize();
  });

  describe("contextTranslator", () => {
    it("defaultContextTranslator provides the correct context", async () => {
      expect(
        defaultContextTranslator({
          userId: 123,
          name: "John Doe",
          email: "[email protected]",
          avatar: "https://reflag.com/avatar.png",
          companyId: "456",
          companyName: "Acme, Inc.",
          companyAvatar: "https://acme.com/company-avatar.png",
          companyPlan: "pro",
        }),
      ).toEqual({
        user: {
          id: "123",
          name: "John Doe",
          email: "[email protected]",
          avatar: "https://reflag.com/avatar.png",
        },
        company: {
          id: "456",
          name: "Acme, Inc.",
          plan: "pro",
          avatar: "https://acme.com/company-avatar.png",
        },
      });
    });

    it("defaultContextTranslator uses targetingKey if provided", async () => {
      expect(
        defaultContextTranslator({
          targetingKey: "123",
        }),
      ).toMatchObject({
        user: {
          id: "123",
        },
        company: {
          id: undefined,
        },
      });
    });
  });

  describe("lifecycle", () => {
    it("calls the constructor of ReflagClient", () => {
      mockReflagClient.mockClear();

      provider = new ReflagNodeProvider({
        secretKey,
        contextTranslator: mockTranslatorFn,
      });

      expect(mockReflagClient).toHaveBeenCalledTimes(1);
      expect(mockReflagClient).toHaveBeenCalledWith({ secretKey });
    });

    it("should set the status to READY if initialization succeeds", async () => {
      provider = new ReflagNodeProvider({
        secretKey,
        contextTranslator: mockTranslatorFn,
      });

      await provider.initialize();

      expect(provider.status).toBe(ProviderStatus.READY);
    });

    it("should keep the status as READY after closing", async () => {
      provider = new ReflagNodeProvider({
        secretKey: "invalid",
        contextTranslator: mockTranslatorFn,
      });

      await provider.initialize();
      await provider.onClose();

      expect(provider.status).toBe(ProviderStatus.READY);
    });

    it("calls flush when provider is closed", async () => {
      await provider.onClose();
      expect(reflagClientMock.flush).toHaveBeenCalledTimes(1);
    });

    it("uses the contextTranslator function", async () => {
      mockFlag(true);

      await provider.resolveBooleanEvaluation(testFlagKey, false, context);

      expect(mockTranslatorFn).toHaveBeenCalledTimes(1);
      expect(mockTranslatorFn).toHaveBeenCalledWith(context);

      expect(reflagClientMock.getFlagDefinitions).toHaveBeenCalledTimes(1);
      expect(reflagClientMock.getFlag).toHaveBeenCalledWith(
        reflagContext,
        testFlagKey,
      );
    });
  });

  describe("resolving flags", () => {
    beforeEach(async () => {
      await provider.initialize();
    });

    it("returns error if provider is not initialized", async () => {
      provider = new ReflagNodeProvider({
        secretKey: "invalid",
        contextTranslator: mockTranslatorFn,
      });

      const val = await provider.resolveBooleanEvaluation(
        testFlagKey,
        true,
        context,
      );

      expect(val).toMatchObject({
        reason: "ERROR",
        errorCode: "PROVIDER_NOT_READY",
        value: true,
      });
    });

    it("returns error if flag is not found", async () => {
      mockFlag(true, "key", true);
      const val = await provider.resolveBooleanEvaluation(
        "missing-key",
        true,
        context,
      );

      expect(val).toMatchObject({
        reason: "ERROR",
        errorCode: "FLAG_NOT_FOUND",
        value: true,
      });
    });

    it("calls the client correctly when evaluating", async () => {
      mockFlag(true, "key", true);

      const val = await provider.resolveBooleanEvaluation(
        testFlagKey,
        false,
        context,
      );

      expect(val).toMatchObject({
        reason: "TARGETING_MATCH",
        value: true,
      });

      expect(reflagClientMock.getFlagDefinitions).toHaveBeenCalled();
      expect(reflagClientMock.getFlag).toHaveBeenCalledWith(
        reflagContext,
        testFlagKey,
      );
    });

    it.each([
      [true, false, true, "TARGETING_MATCH", undefined],
      [undefined, true, true, "ERROR", "FLAG_NOT_FOUND"],
      [undefined, false, false, "ERROR", "FLAG_NOT_FOUND"],
    ])(
      "should return the correct result when evaluating boolean. enabled: %s, value: %s, default: %s, expected: %s, reason: %s, errorCode: %s`",
      async (enabled, def, expected, reason, errorCode) => {
        const configKey = enabled !== undefined ? "variant-1" : undefined;

        mockFlag(enabled ?? false, configKey);
        const flagKey = enabled ? testFlagKey : "missing-key";

        expect(
          await provider.resolveBooleanEvaluation(flagKey, def, context),
        ).toMatchObject({
          reason,
          value: expected,
          ...(configKey ? { variant: configKey } : {}),
          ...(errorCode ? { errorCode } : {}),
        });
      },
    );

    it("should return error when context is missing user ID", async () => {
      mockTranslatorFn.mockReturnValue({ user: {} });

      expect(
        await provider.resolveBooleanEvaluation(testFlagKey, true, context),
      ).toMatchObject({
        reason: "ERROR",
        errorCode: "INVALID_CONTEXT",
        value: true,
      });
    });

    it("should return error when evaluating number", async () => {
      expect(
        await provider.resolveNumberEvaluation(testFlagKey, 1),
      ).toMatchObject({
        reason: "ERROR",
        errorCode: "GENERAL",
        value: 1,
      });
    });

    it.each([
      ["key-1", "default", "key-1", "TARGETING_MATCH"],
      [null, "default", "default", "DEFAULT"],
      [undefined, "default", "default", "DEFAULT"],
    ])(
      "should return the correct result when evaluating string. variant: %s, def: %s, expected: %s, reason: %s, errorCode: %s`",
      async (variant, def, expected, reason) => {
        mockFlag(true, variant, {});
        expect(
          await provider.resolveStringEvaluation(testFlagKey, def, context),
        ).toMatchObject({
          reason,
          value: expected,
          ...(variant ? { variant } : {}),
        });
      },
    );

    it.each([
      [{}, { a: 1 }, {}, "TARGETING_MATCH", undefined],
      ["string", "default", "string", "TARGETING_MATCH", undefined],
      [15, -15, 15, "TARGETING_MATCH", undefined],
      [true, false, true, "TARGETING_MATCH", undefined],
      [null, { a: 2 }, { a: 2 }, "ERROR", "TYPE_MISMATCH"],
      [100, "string", "string", "ERROR", "TYPE_MISMATCH"],
      [true, 1337, 1337, "ERROR", "TYPE_MISMATCH"],
      ["string", 1337, 1337, "ERROR", "TYPE_MISMATCH"],
      [undefined, "default", "default", "ERROR", "TYPE_MISMATCH"],
    ])(
      "should return the correct result when evaluating object. payload: %s, default: %s, expected: %s, reason: %s, errorCode: %s`",
      async (value, def, expected, reason, errorCode) => {
        const configKey = value === undefined ? undefined : "config-key";
        mockFlag(true, configKey, value);
        expect(
          await provider.resolveObjectEvaluation(testFlagKey, def, context),
        ).toMatchObject({
          reason,
          value: expected,
          ...(errorCode ? { errorCode, variant: configKey } : {}),
        });
      },
    );
  });

  describe("track", () => {
    it("should track", async () => {
      expect(mockTranslatorFn).toHaveBeenCalledTimes(0);
      provider.track("event", context, {
        action: "click",
      });

      expect(mockTranslatorFn).toHaveBeenCalledTimes(1);
      expect(mockTranslatorFn).toHaveBeenCalledWith(context);
      expect(reflagClientMock.track).toHaveBeenCalledTimes(1);
      expect(reflagClientMock.track).toHaveBeenCalledWith("42", "event", {
        attributes: { action: "click" },
        companyId: reflagContext.company.id,
      });
    });
  });
});

```

--------------------------------------------------------------------------------
/packages/browser-sdk/src/feedback/feedback.ts:
--------------------------------------------------------------------------------

```typescript
import { HttpClient } from "../httpClient";
import { Logger } from "../logger";
import { AblySSEChannel, openAblySSEChannel } from "../sse";
import { Position } from "../ui/types";

import {
  FeedbackSubmission,
  FeedbackTranslations,
  OpenFeedbackFormOptions,
} from "./ui/types";
import {
  FeedbackPromptCompletionHandler,
  parsePromptMessage,
  processPromptMessage,
} from "./prompts";
import { getAuthToken } from "./promptStorage";
import * as feedbackLib from "./ui";
import { DEFAULT_POSITION } from "./ui";

export type Key = string;

export type FeedbackOptions = {
  /**
   * Enables automatic feedback prompting if it's set up in Reflag
   */
  enableAutoFeedback?: boolean;

  /**
   *
   */
  autoFeedbackHandler?: FeedbackPromptHandler;

  /**
   * With these options you can override the look of the feedback prompt
   */
  ui?: {
    /**
     * Control the placement and behavior of the feedback form.
     */
    position?: Position;

    /**
     * Add your own custom translations for the feedback form.
     * Undefined translation keys fall back to english defaults.
     */
    translations?: Partial<FeedbackTranslations>;
  };
};

export type RequestFeedbackData = Omit<
  OpenFeedbackFormOptions,
  "key" | "onSubmit"
> & {
  /**
   * Company ID from your own application.
   */
  companyId?: string;

  /**
   * Allows you to handle a copy of the already submitted
   * feedback.
   *
   * This can be used for side effects, such as storing a
   * copy of the feedback in your own application or CRM.
   *
   * @param {Object} data
   */
  onAfterSubmit?: (data: FeedbackSubmission) => void;

  /**
   * Flag key.
   */
  flagKey: string;
};

export type RequestFeedbackOptions = RequestFeedbackData & {
  /**
   * User ID from your own application.
   */
  userId: string;
};

export type UnassignedFeedback = {
  /**
   * Flag key.
   */
  flagKey: string;

  /**
   * Reflag feedback ID
   */
  feedbackId?: string;

  /**
   * The question that was presented to the user.
   */
  question?: string;

  /**
   * The original question.
   * This only needs to be populated if the feedback was submitted through the automated feedback surveys channel.
   */
  promptedQuestion?: string;

  /**
   * Customer satisfaction score.
   */
  score?: number;

  /**
   * User supplied comment about your flag.
   */
  comment?: string;

  /**
   * Reflag feedback prompt ID.
   *
   * This only exists if the feedback was submitted
   * as part of an automated prompt from Reflag.
   *
   * Used for internal state management of automated
   * feedback.
   */
  promptId?: string;

  /**
   * Source of the feedback, depending on how the user was asked
   * - `prompt` - Feedback submitted by way of an automated feedback survey (prompted)
   * - `widget` - Feedback submitted via `requestFeedback`
   * - `sdk` - Feedback submitted via `feedback`
   */
  source?: "prompt" | "sdk" | "widget";
};

export type Feedback = UnassignedFeedback & {
  /**
   * User ID from your own application.
   */
  userId?: string;

  /**
   * Company ID from your own application.
   */
  companyId?: string;
};

export type FeedbackPrompt = {
  /**
   * Specific question user was asked
   */
  question: string;

  /**
   * Feedback prompt should appear only after this time
   */
  showAfter: Date;

  /**
   * Feedback prompt will not be shown after this time
   */
  showBefore: Date;

  /**
   * Id of the prompt
   */
  promptId: string;

  /**
   * Feature ID from Reflag
   */
  featureId: string;
};

export type FeedbackPromptReply = {
  question: string;
  companyId?: string;
  score?: number;
  comment?: string;
};

export type FeedbackPromptReplyHandler = <T extends FeedbackPromptReply | null>(
  reply: T,
) => T extends null ? Promise<void> : Promise<{ feedbackId: string }>;

export type FeedbackPromptHandlerOpenFeedbackFormOptions = Omit<
  RequestFeedbackOptions,
  "featureId" | "flagKey" | "userId" | "companyId" | "onClose" | "onDismiss"
>;

export type FeedbackPromptHandlerCallbacks = {
  reply: FeedbackPromptReplyHandler;
  openFeedbackForm: (
    options: FeedbackPromptHandlerOpenFeedbackFormOptions,
  ) => void;
};

export type FeedbackPromptHandler = (
  prompt: FeedbackPrompt,
  handlers: FeedbackPromptHandlerCallbacks,
) => void;

export const createDefaultFeedbackPromptHandler = (
  options: FeedbackPromptHandlerOpenFeedbackFormOptions = {},
): FeedbackPromptHandler => {
  return (_prompt: FeedbackPrompt, handlers) => {
    handlers.openFeedbackForm(options);
  };
};
export const DEFAULT_FEEDBACK_CONFIG = {
  promptHandler: createDefaultFeedbackPromptHandler(),
  feedbackPosition: DEFAULT_POSITION,
  translations: {},
  autoFeedbackEnabled: true,
};

// Payload can include featureId or flagKey, but the public API only exposes flagKey
// We use featureId internally because prompting is based on featureId
type FeedbackPayload = Omit<Feedback, "flagKey"> & {
  featureId?: string;
  flagKey?: string;
};

export async function feedback(
  httpClient: HttpClient,
  logger: Logger,
  payload: FeedbackPayload,
) {
  if (!payload.score && !payload.comment) {
    logger.error(
      "`feedback` call ignored, either `score` or `comment` must be provided",
    );
    return;
  }

  if (!payload.userId) {
    logger.error("`feedback` call ignored, no `userId` provided");
    return;
  }

  const featureId = "featureId" in payload ? payload.featureId : undefined;
  const flagKey = "flagKey" in payload ? payload.flagKey : undefined;

  if (!featureId && !flagKey) {
    logger.error(
      "`feedback` call ignored. Neither `featureId` nor `flagKey` have been provided",
    );
    return;
  }

  // set default source to sdk
  const feedbackPayload = {
    ...payload,
    flagKey: undefined,
    source: payload.source ?? "sdk",
    featureId,
    key: flagKey,
  };

  const res = await httpClient.post({
    path: `/feedback`,
    body: feedbackPayload,
  });

  logger.debug(`sent feedback`, res);
  return res;
}

export class AutoFeedback {
  private initialized = false;
  private sseChannel: AblySSEChannel | null = null;

  constructor(
    private sseBaseUrl: string,
    private logger: Logger,
    private httpClient: HttpClient,
    private feedbackPromptHandler: FeedbackPromptHandler = createDefaultFeedbackPromptHandler(),
    private userId: string,
    private position: Position = DEFAULT_POSITION,
    private feedbackTranslations: Partial<FeedbackTranslations> = {},
  ) {}

  /**
   * Start receiving automated feedback surveys.
   */
  async initialize() {
    if (this.initialized) {
      this.logger.warn("automatic feedback client already initialized");
      return;
    }
    this.initialized = true;

    const channel = await this.getChannel();
    if (!channel) return;

    try {
      this.logger.debug(`automatic feedback enabled`, channel);
      this.sseChannel = openAblySSEChannel({
        userId: this.userId,
        channel,
        httpClient: this.httpClient,
        callback: (message) =>
          this.handleFeedbackPromptRequest(this.userId, message),
        logger: this.logger,
        sseBaseUrl: this.sseBaseUrl,
      });
      this.logger.debug(`automatic feedback connection established`);
    } catch (e) {
      this.logger.error(`error initializing automatic feedback client`, e);
    }
  }

  stop() {
    if (this.sseChannel) {
      this.sseChannel.close();
      this.sseChannel = null;
    }
  }

  async setUser(userId: string) {
    this.stop();
    this.initialized = false;
    this.userId = userId;
    await this.initialize();
  }

  handleFeedbackPromptRequest(userId: string, message: any) {
    const parsed = parsePromptMessage(message);
    if (!parsed) {
      this.logger.error(`invalid feedback prompt message received`, message);
    } else {
      if (
        !processPromptMessage(userId, parsed, async (u, m, cb) => {
          await this.feedbackPromptEvent({
            promptId: parsed.promptId,
            featureId: parsed.featureId,
            promptedQuestion: parsed.question,
            event: "received",
            userId,
          });
          await this.triggerFeedbackPrompt(u, m, cb);
        })
      ) {
        this.logger.info(
          `feedback prompt not shown, it was either expired or already processed`,
          message,
        );
      }
    }
  }

  async triggerFeedbackPrompt(
    userId: string,
    message: FeedbackPrompt,
    completionHandler: FeedbackPromptCompletionHandler,
  ) {
    let feedbackId: string | undefined = undefined;

    await this.feedbackPromptEvent({
      promptId: message.promptId,
      featureId: message.featureId,
      promptedQuestion: message.question,
      event: "shown",
      userId,
    });

    const replyCallback: FeedbackPromptReplyHandler = async (reply) => {
      if (!reply) {
        await this.feedbackPromptEvent({
          promptId: message.promptId,
          featureId: message.featureId,
          event: "dismissed",
          userId,
          promptedQuestion: message.question,
        });

        completionHandler();
        return;
      }

      const feedbackPayload = {
        feedbackId: feedbackId,
        featureId: message.featureId,
        userId,
        companyId: reply.companyId,
        score: reply.score,
        comment: reply.comment,
        promptId: message.promptId,
        question: reply.question,
        promptedQuestion: message.question,
        source: "prompt",
      } satisfies FeedbackPayload;

      const response = await feedback(
        this.httpClient,
        this.logger,
        feedbackPayload,
      );

      completionHandler();

      if (response && response.ok) {
        return await response?.json();
      }
      return;
    };

    const handlers: FeedbackPromptHandlerCallbacks = {
      reply: replyCallback,
      openFeedbackForm: (options) => {
        feedbackLib.openFeedbackForm({
          key: message.featureId,
          title: message.question,
          onScoreSubmit: async (data) => {
            const res = await replyCallback(data);
            feedbackId = res.feedbackId;
            return { feedbackId: res.feedbackId };
          },
          onSubmit: async (data) => {
            await replyCallback(data);
            options.onAfterSubmit?.(data);
          },
          onDismiss: () => replyCallback(null),
          position: this.position,
          translations: this.feedbackTranslations,
          ...options,
        });
      },
    };

    this.feedbackPromptHandler(message, handlers);
  }

  async feedbackPromptEvent(args: {
    event: "received" | "shown" | "dismissed";
    featureId: string;
    promptId: string;
    promptedQuestion: string;
    userId: string;
  }) {
    const payload = {
      action: args.event,
      featureId: args.featureId,
      promptId: args.promptId,
      userId: args.userId,
      promptedQuestion: args.promptedQuestion,
    };

    const res = await this.httpClient.post({
      path: `/feedback/prompt-events`,
      body: payload,
    });
    this.logger.debug(`sent prompt event`, res);
    return res;
  }

  private async getChannel() {
    const existingAuth = getAuthToken(this.userId);
    const channel = existingAuth?.channel;

    if (channel) {
      return channel;
    }

    try {
      if (!channel) {
        const res = await this.httpClient.post({
          path: `/feedback/prompting-init`,
          body: {
            userId: this.userId,
          },
        });

        this.logger.debug(`automatic feedback status sent`, res);
        if (res.ok) {
          const body: { success: boolean; channel?: string } = await res.json();
          if (body.success && body.channel) {
            return body.channel;
          }
        }
      }
    } catch (e) {
      this.logger.error(`error initializing automatic feedback`, e);
      return;
    }
    return;
  }
}

```

--------------------------------------------------------------------------------
/packages/cli/test/json.test.ts:
--------------------------------------------------------------------------------

```typescript
import { describe, expect, it } from "vitest";

import {
  JSONToType,
  mergeTypeASTs,
  quoteKey,
  stringifyTypeAST,
  toTypeAST,
  TypeAST,
} from "../utils/json.js";

describe("JSON utilities", () => {
  describe("toTypeAST", () => {
    it("should handle primitive values", () => {
      expect(toTypeAST("test")).toEqual({ kind: "primitive", type: "string" });
      expect(toTypeAST(42)).toEqual({ kind: "primitive", type: "number" });
      expect(toTypeAST(true)).toEqual({ kind: "primitive", type: "boolean" });
      expect(toTypeAST(null)).toEqual({ kind: "primitive", type: "null" });
    });

    it("should handle arrays", () => {
      expect(toTypeAST([1, 2, 3])).toEqual({
        kind: "array",
        elementType: { kind: "primitive", type: "number" },
      });

      expect(toTypeAST([])).toEqual({
        kind: "array",
        elementType: { kind: "primitive", type: "any" },
      });
    });

    it("should handle arrays with mixed element types", () => {
      expect(toTypeAST([1, "test", true])).toEqual({
        kind: "array",
        elementType: {
          kind: "union",
          types: [
            { kind: "primitive", type: "number" },
            { kind: "primitive", type: "string" },
            { kind: "primitive", type: "boolean" },
          ],
        },
      });

      expect(toTypeAST([{ name: "John" }, { age: 30 }])).toEqual({
        kind: "array",
        elementType: {
          kind: "object",
          properties: [
            {
              key: "name",
              type: { kind: "primitive", type: "string" },
              optional: true,
            },
            {
              key: "age",
              type: { kind: "primitive", type: "number" },
              optional: true,
            },
          ],
        },
      });
    });

    it("should handle objects", () => {
      expect(toTypeAST({ name: "John", age: 30 })).toEqual({
        kind: "object",
        properties: [
          {
            key: "name",
            type: { kind: "primitive", type: "string" },
            optional: false,
          },
          {
            key: "age",
            type: { kind: "primitive", type: "number" },
            optional: false,
          },
        ],
      });
    });

    it("should handle nested structures", () => {
      const input = {
        user: {
          name: "John",
          contacts: [{ email: "[email protected]" }],
        },
      };

      const expected: TypeAST = {
        kind: "object",
        properties: [
          {
            key: "user",
            type: {
              kind: "object",
              properties: [
                {
                  key: "name",
                  type: { kind: "primitive", type: "string" },
                  optional: false,
                },
                {
                  key: "contacts",
                  type: {
                    kind: "array",
                    elementType: {
                      kind: "object",
                      properties: [
                        {
                          key: "email",
                          type: { kind: "primitive", type: "string" },
                          optional: false,
                        },
                      ],
                    },
                  },
                  optional: false,
                },
              ],
            },
            optional: false,
          },
        ],
      };

      expect(toTypeAST(input)).toEqual(expected);
    });
  });

  describe("mergeTypeASTs", () => {
    it("should handle empty array", () => {
      expect(mergeTypeASTs([])).toEqual({ kind: "primitive", type: "any" });
    });

    it("should return the same AST for single item arrays", () => {
      const ast: TypeAST = { kind: "primitive", type: "string" };
      expect(mergeTypeASTs([ast])).toEqual(ast);
    });

    it("should merge same primitive types", () => {
      const types: TypeAST[] = [
        { kind: "primitive", type: "number" },
        { kind: "primitive", type: "number" },
      ];
      expect(mergeTypeASTs(types)).toEqual({
        kind: "primitive",
        type: "number",
      });
    });

    it("should create union for different primitive types", () => {
      const types: TypeAST[] = [
        { kind: "primitive", type: "string" },
        { kind: "primitive", type: "number" },
      ];
      expect(mergeTypeASTs(types)).toEqual({
        kind: "union",
        types: [
          { kind: "primitive", type: "string" },
          { kind: "primitive", type: "number" },
        ],
      });
    });

    it("should merge array types", () => {
      const types: TypeAST[] = [
        { kind: "array", elementType: { kind: "primitive", type: "number" } },
        { kind: "array", elementType: { kind: "primitive", type: "string" } },
      ];
      expect(mergeTypeASTs(types)).toEqual({
        kind: "array",
        elementType: {
          kind: "union",
          types: [
            { kind: "primitive", type: "number" },
            { kind: "primitive", type: "string" },
          ],
        },
      });
    });

    it("should merge object types and mark missing properties as optional", () => {
      const types: TypeAST[] = [
        {
          kind: "object",
          properties: [
            {
              key: "name",
              type: { kind: "primitive", type: "string" },
              optional: false,
            },
            {
              key: "age",
              type: { kind: "primitive", type: "number" },
              optional: false,
            },
          ],
        },
        {
          kind: "object",
          properties: [
            {
              key: "name",
              type: { kind: "primitive", type: "string" },
              optional: false,
            },
            {
              key: "email",
              type: { kind: "primitive", type: "string" },
              optional: false,
            },
          ],
        },
      ];

      expect(mergeTypeASTs(types)).toEqual({
        kind: "object",
        properties: [
          {
            key: "name",
            type: { kind: "primitive", type: "string" },
            optional: false,
          },
          {
            key: "age",
            type: { kind: "primitive", type: "number" },
            optional: true,
          },
          {
            key: "email",
            type: { kind: "primitive", type: "string" },
            optional: true,
          },
        ],
      });
    });

    it("should create union for mixed kinds", () => {
      const types: TypeAST[] = [
        { kind: "primitive", type: "string" },
        { kind: "array", elementType: { kind: "primitive", type: "number" } },
      ];

      expect(mergeTypeASTs(types)).toEqual({
        kind: "union",
        types,
      });
    });
  });

  describe("stringifyTypeAST", () => {
    it("should stringify primitive types", () => {
      expect(stringifyTypeAST({ kind: "primitive", type: "string" })).toBe(
        "string",
      );
      expect(stringifyTypeAST({ kind: "primitive", type: "number" })).toBe(
        "number",
      );
      expect(stringifyTypeAST({ kind: "primitive", type: "boolean" })).toBe(
        "boolean",
      );
      expect(stringifyTypeAST({ kind: "primitive", type: "null" })).toBe(
        "null",
      );
    });

    it("should stringify array types", () => {
      expect(
        stringifyTypeAST({
          kind: "array",
          elementType: { kind: "primitive", type: "string" },
        }),
      ).toBe("(string)[]");
    });

    it("should stringify object types", () => {
      const ast: TypeAST = {
        kind: "object",
        properties: [
          {
            key: "name",
            type: { kind: "primitive", type: "string" },
            optional: false,
          },
          {
            key: "age",
            type: { kind: "primitive", type: "number" },
            optional: true,
          },
        ],
      };

      const expected = `{\n  name: string,\n  age?: number\n}`;
      expect(stringifyTypeAST(ast)).toBe(expected);
    });

    it("should stringify empty objects", () => {
      expect(stringifyTypeAST({ kind: "object", properties: [] })).toBe("{}");
    });

    it("should quote object keys with special characters", () => {
      const ast: TypeAST = {
        kind: "object",
        properties: [
          {
            key: "my-key",
            type: { kind: "primitive", type: "string" },
            optional: false,
          },
          {
            key: "my key",
            type: { kind: "primitive", type: "string" },
            optional: false,
          },
          {
            key: "my.key",
            type: { kind: "primitive", type: "string" },
            optional: false,
          },
          {
            key: "123key",
            type: { kind: "primitive", type: "string" },
            optional: false,
          },
        ],
      };

      const expected = `{\n  "my-key": string,\n  "my key": string,\n  "my.key": string,\n  "123key": string\n}`;
      expect(stringifyTypeAST(ast)).toBe(expected);
    });

    it("should stringify union types", () => {
      const ast: TypeAST = {
        kind: "union",
        types: [
          { kind: "primitive", type: "string" },
          { kind: "primitive", type: "number" },
        ],
      };

      expect(stringifyTypeAST(ast)).toBe("string | number");
    });

    it("should handle complex nested types", () => {
      const ast: TypeAST = {
        kind: "object",
        properties: [
          {
            key: "user",
            type: {
              kind: "object",
              properties: [
                {
                  key: "name",
                  type: { kind: "primitive", type: "string" },
                  optional: false,
                },
                {
                  key: "contacts",
                  type: {
                    kind: "array",
                    elementType: {
                      kind: "object",
                      properties: [
                        {
                          key: "email",
                          type: { kind: "primitive", type: "string" },
                          optional: false,
                        },
                      ],
                    },
                  },
                  optional: false,
                },
              ],
            },
            optional: false,
          },
        ],
      };

      const expected =
        `{\n  user: {\n    name: string,\n    contacts: ({\n      email: string\n    })[]` +
        `\n  }\n}`;
      expect(stringifyTypeAST(ast)).toBe(expected);
    });
  });

  describe("JSONToType", () => {
    it("should handle empty arrays", () => {
      expect(JSONToType([])).toBeNull();
    });

    it("should generate type for array of primitives", () => {
      expect(JSONToType([1, 2, 3])).toBe("number");
      expect(JSONToType(["a", "b", "c"])).toBe("string");
      expect(JSONToType([1, "a", true])).toBe("number | string | boolean");
    });

    it("should handle arrays with simple mixed element types", () => {
      const expected = "(number | string | boolean)[]";
      expect(JSONToType([["a", true], [1]])).toBe(expected);
    });

    it("should handle arrays with advanced mixed element types", () => {
      const expected =
        "(number | string | boolean | {\n  id?: number,\n  name?: string\n})[]";
      expect(
        JSONToType([
          [1, "a", true],
          [{ id: 1 }, { name: "test" }],
        ]),
      ).toBe(expected);
    });

    it("should merge arrays with nested arrays of mixed element types", () => {
      const expected = "((boolean | number | string | {\n  id: number\n})[])[]";
      expect(JSONToType([[[1, "test"], [true]], [[{ id: 1 }]]])).toBe(expected);
    });

    it("should generate type for array of objects", () => {
      const expected = `{\n  name: string,\n  age?: number,\n  email?: string\n}`;
      expect(
        JSONToType([
          { name: "John", age: 30 },
          { name: "Jane", email: "[email protected]" },
        ]),
      ).toBe(expected);
    });

    it("should handle complex nested structures", () => {
      const expected =
        `{\n  user: {\n    name: string,\n    settings: {\n      theme?: string,` +
        `\n      notifications?: boolean\n    }\n  }\n}`;

      expect(
        JSONToType([
          {
            user: {
              name: "John",
              settings: { theme: "dark" },
            },
          },
          {
            user: {
              name: "Jane",
              settings: { notifications: true },
            },
          },
        ]),
      ).toBe(expected);
    });
  });

  describe("quoteKey", () => {
    it("should quote keys with special characters", () => {
      expect(quoteKey("my-key")).toBe('"my-key"');
      expect(quoteKey("my key")).toBe('"my key"');
      expect(quoteKey("my.key")).toBe('"my.key"');
      expect(quoteKey("123key")).toBe('"123key"');
      expect(quoteKey("key")).toBe("key");
    });
  });
});

```

--------------------------------------------------------------------------------
/packages/browser-sdk/test/e2e/feedback-widget.browser.spec.ts:
--------------------------------------------------------------------------------

```typescript
import { randomUUID } from "crypto";
import { expect, Locator, Page, test } from "@playwright/test";

import { InitOptions } from "../../src/client";
import { DEFAULT_TRANSLATIONS } from "../../src/feedback/ui/config/defaultTranslations";
import { FeedbackTranslations } from "../../src/feedback/ui/types";
import { feedbackContainerId, propagatedEvents } from "../../src/ui/constants";

const KEY = randomUUID();
const API_HOST = `https://front.reflag.com`;

const WINDOW_WIDTH = 1280;
const WINDOW_HEIGHT = 720;

declare global {
  interface Window {
    eventsFired: Record<string, boolean>;
  }
}

function pick<T>(options: T[]): T {
  return options[Math.floor(Math.random() * options.length)];
}

async function getOpenedWidgetContainer(
  page: Page,
  initOptions: Omit<InitOptions, "publishableKey"> = {},
) {
  await page.goto("http://localhost:8001/test/e2e/empty.html");

  // Mock API calls
  await page.route(`${API_HOST}/user`, async (route) => {
    await route.fulfill({ status: 200 });
  });

  await page.route(`${API_HOST}/features/evaluated*`, async (route) => {
    await route.fulfill({
      status: 200,
      body: JSON.stringify({
        success: true,
        features: {},
      }),
    });
  });

  // Golden path requests
  await page.evaluate(`
    ;(async () => {
      const { ReflagClient } = await import("/dist/reflag-browser-sdk.mjs");
      const reflag = new ReflagClient({publishableKey: "${KEY}", user: {id: "foo"}, company: {id: "bar"}, ...${JSON.stringify(initOptions ?? {})}});
      await reflag.initialize();
      await reflag.requestFeedback({
        flagKey: "flag1",
        title: "baz",
      });
    })()
  `);

  return page.locator(`#${feedbackContainerId}`);
}

async function getGiveFeedbackPageContainer(
  page: Page,
  initOptions: Omit<InitOptions, "publishableKey"> = {},
) {
  await page.goto("http://localhost:8001/test/e2e/give-feedback-button.html");

  // Mock API calls
  await page.route(`${API_HOST}/user`, async (route) => {
    await route.fulfill({ status: 200 });
  });

  await page.route(`${API_HOST}/features/evaluated*`, async (route) => {
    await route.fulfill({
      status: 200,
      body: JSON.stringify({
        success: true,
        features: {},
      }),
    });
  });

  // Golden path requests
  await page.evaluate(`
    ;(async () => {
      const { ReflagClient } = await import("/dist/reflag-browser-sdk.mjs");
      const reflag = new ReflagClient({publishableKey: "${KEY}", user: {id: "foo"}, company: {id: "bar"}, ...${JSON.stringify(initOptions ?? {})}});
      await reflag.initialize();
      console.log("setup clicky", document.querySelector("#give-feedback-button"))
      document.querySelector("#give-feedback-button")?.addEventListener("click", () => {
        console.log("cliked!");
        reflag.requestFeedback({
          flagKey: "flag1",
          title: "baz",
        });
      });
    })()
  `);

  return page.locator(`#${feedbackContainerId}`);
}

async function setScore(container: Locator, score: number) {
  await new Promise((resolve) => setTimeout(resolve, 50)); // allow react to update its state
  await container
    .locator(`#reflag-feedback-score-${score}`)
    .dispatchEvent("click");
}

async function setComment(container: Locator, comment: string) {
  await container.locator("#reflag-feedback-comment-label").fill(comment);
}

async function submitForm(container: Locator) {
  await container.locator(".form-expanded-content").getByRole("button").click();
}

test.beforeEach(async ({ page, browserName }) => {
  // Log any calls to front.reflag.com which aren't mocked by subsequent
  // `page.route` calls. With page.route, the last matching mock takes
  // precedence, so this logs any which may have been missed, and responds
  // with a 200 to prevent an internet request.
  await page.route(/^https:\/\/front\.reflag\.com.*/, async (route) => {
    const meta = `${route.request().method()} ${route.request().url()}`;

    console.debug(`\n Unmocked request:        [${browserName}] > ${meta}`);
    console.debug(`Sent stub mock response: [${browserName}] < ${meta} 200\n`);

    await route.fulfill({ status: 200, body: "{}" });
  });

  // Mock prompting-init as if prompting is `disabled` for all tests.
  await page.route(`${API_HOST}/feedback/prompting-init`, async (route) => {
    await route.fulfill({
      status: 200,
      body: JSON.stringify({ success: false }),
    });
  });
});

test("Opens a feedback widget", async ({ page }) => {
  const container = await getOpenedWidgetContainer(page);

  await expect(container).toBeAttached();
  await expect(container.locator("dialog")).toHaveAttribute("open", "");
});

test("Opens a feedback widget multiple times in same session", async ({
  page,
}) => {
  const container = await getGiveFeedbackPageContainer(page);

  await page.getByTestId("give-feedback-button").click();
  await expect(container).toBeAttached();
  await expect(container.locator("dialog")).toHaveAttribute("open", "");

  await container.locator("dialog .close").click();
  await expect(container.locator("dialog")).not.toHaveAttribute("open", "");

  await page.getByTestId("give-feedback-button").click();
  await expect(container).toBeAttached();
  await expect(container.locator("dialog")).toHaveAttribute("open", "");
});

test("Opens a feedback widget in the bottom right by default", async ({
  page,
}) => {
  const container = await getOpenedWidgetContainer(page);

  await expect(container).toBeAttached();

  const bbox = await container.locator("dialog").boundingBox();
  expect(bbox?.x).toEqual(WINDOW_WIDTH - bbox!.width - 16);
  expect(bbox?.y).toBeGreaterThan(WINDOW_HEIGHT - bbox!.height - 30); // Account for browser differences
  expect(bbox?.y).toBeLessThan(WINDOW_HEIGHT - bbox!.height);
});

test("Opens a feedback widget in the correct position when overridden", async ({
  page,
}) => {
  const container = await getOpenedWidgetContainer(page, {
    feedback: {
      ui: {
        position: {
          type: "DIALOG",
          placement: "top-left",
        },
      },
    },
  });

  await expect(container).toBeAttached();

  const bbox = await container.locator("dialog").boundingBox();
  expect(bbox?.x).toEqual(16);
  expect(bbox?.y).toBeGreaterThan(0); // Account for browser differences
  expect(bbox?.y).toBeLessThanOrEqual(16);
});

test("Opens a feedback widget with the correct translations", async ({
  page,
}) => {
  const translations: Partial<FeedbackTranslations> = {
    ScoreStatusDescription: "Choisissez une note et laissez un commentaire",
    ScoreVeryDissatisfiedLabel: "Très insatisfait",
    ScoreDissatisfiedLabel: "Insatisfait",
    ScoreNeutralLabel: "Neutre",
    ScoreSatisfiedLabel: "Satisfait",
    ScoreVerySatisfiedLabel: "Très satisfait",
    SendButton: "Envoyer",
  };

  const container = await getOpenedWidgetContainer(page, {
    feedback: {
      ui: {
        translations,
      },
    },
  });

  await expect(container).toBeAttached();
  await expect(container).toContainText(translations.ScoreStatusDescription!);
  await expect(container).toContainText(
    translations.ScoreVeryDissatisfiedLabel!,
  );
  await expect(container).toContainText(translations.ScoreDissatisfiedLabel!);
  await expect(container).toContainText(translations.ScoreNeutralLabel!);
  await expect(container).toContainText(translations.ScoreSatisfiedLabel!);
  await expect(container).toContainText(translations.ScoreVerySatisfiedLabel!);
  await expect(container).toContainText(translations.SendButton!);
});

test("Sends a request when choosing a score immediately", async ({ page }) => {
  const expectedScore = pick([1, 2, 3, 4, 5]);
  let sentJSON: object | null = null;

  await page.route(`${API_HOST}/feedback`, async (route) => {
    sentJSON = route.request().postDataJSON();
    await route.fulfill({
      status: 200,
      body: JSON.stringify({ feedbackId: "123" }),
      contentType: "application/json",
    });
  });

  const container = await getOpenedWidgetContainer(page);
  await setScore(container, expectedScore);

  await expect
    .poll(() => sentJSON)
    .toEqual({
      companyId: "bar",
      key: "flag1",
      score: expectedScore,
      question: "baz",
      userId: "foo",
      source: "widget",
    });
});

test("Shows a success message after submitting a score", async ({ page }) => {
  await page.route(`${API_HOST}/feedback`, async (route) => {
    await route.fulfill({
      status: 200,
      body: JSON.stringify({ feedbackId: "123" }),
      contentType: "application/json",
    });
  });

  const container = await getOpenedWidgetContainer(page);

  await expect(
    container.getByText(DEFAULT_TRANSLATIONS.ScoreStatusDescription),
  ).toHaveCSS("opacity", "1");
  await expect(
    container.getByText(DEFAULT_TRANSLATIONS.ScoreStatusReceived),
  ).toHaveCSS("opacity", "0");

  await setScore(container, 3);

  await expect(
    container.getByText(DEFAULT_TRANSLATIONS.ScoreStatusDescription),
  ).toHaveCSS("opacity", "0");
  await expect(
    container.getByText(DEFAULT_TRANSLATIONS.ScoreStatusReceived),
  ).toHaveCSS("opacity", "1");
});

test("Updates the score on every change", async ({ page }) => {
  let lastSentJSON: object | null = null;

  await page.route(`${API_HOST}/feedback`, async (route) => {
    lastSentJSON = route.request().postDataJSON();
    await route.fulfill({
      status: 200,
      body: JSON.stringify({ feedbackId: "123" }),
      contentType: "application/json",
    });
  });

  const container = await getOpenedWidgetContainer(page);

  await setScore(container, 1);
  await setScore(container, 5);
  await setScore(container, 3);

  await expect
    .poll(() => lastSentJSON)
    .toEqual({
      feedbackId: "123",
      companyId: "bar",
      key: "flag1",
      question: "baz",
      score: 3,
      userId: "foo",
      source: "widget",
    });
});

test("Shows the comment field after submitting a score", async ({ page }) => {
  await page.route(`${API_HOST}/feedback`, async (route) => {
    await route.fulfill({
      status: 200,
      body: JSON.stringify({ feedbackId: "123" }),
      contentType: "application/json",
    });
  });

  const container = await getOpenedWidgetContainer(page);

  await expect(container.locator(".form-expanded-content")).toHaveCSS(
    "opacity",
    "0",
  );

  await setScore(container, 1);

  await expect(container.locator(".form-expanded-content")).toHaveCSS(
    "opacity",
    "1",
  );
});

test("Sends a request with both the score and comment when submitting", async ({
  page,
}) => {
  const expectedComment = `This is my comment: ${Math.random()}`;
  const expectedScore = pick([1, 2, 3, 4, 5]);

  let sentJSON: object | null = null;

  await page.route(`${API_HOST}/feedback`, async (route) => {
    sentJSON = route.request().postDataJSON();
    await route.fulfill({
      status: 200,
      body: JSON.stringify({ feedbackId: "123" }),
      contentType: "application/json",
    });
  });

  const container = await getOpenedWidgetContainer(page);

  await setScore(container, expectedScore);
  await setComment(container, expectedComment);
  await submitForm(container);

  expect(sentJSON).toEqual({
    comment: expectedComment,
    score: expectedScore,
    companyId: "bar",
    question: "baz",
    key: "flag1",
    feedbackId: "123",
    userId: "foo",
    source: "widget",
  });
});

test("Shows a success message after submitting", async ({ page }) => {
  await page.route(`${API_HOST}/feedback`, async (route) => {
    await route.fulfill({
      status: 200,
      body: JSON.stringify({ feedbackId: "123" }),
      contentType: "application/json",
    });
  });

  const container = await getOpenedWidgetContainer(page);

  await setScore(container, 3);
  await setComment(container, "Test comment!");
  await submitForm(container);

  await expect(
    container.getByText(DEFAULT_TRANSLATIONS.SuccessMessage),
  ).toBeVisible();
});

test("Closes the dialog shortly after submitting", async ({ page }) => {
  await page.route(`${API_HOST}/feedback`, async (route) => {
    await route.fulfill({
      status: 200,
      body: JSON.stringify({ feedbackId: "123" }),
      contentType: "application/json",
    });
  });

  const container = await getOpenedWidgetContainer(page);

  await setScore(container, 3);
  await setComment(container, "Test comment!");
  await submitForm(container);

  await expect(container.locator("dialog")).not.toHaveAttribute("open", "");
});

test("Blocks event propagation to the containing document", async ({
  page,
}) => {
  const container = await getOpenedWidgetContainer(page);
  const textarea = container.locator('textarea[name="comment"]');

  await page.evaluate(
    ({ trackedEvents }) => {
      window.eventsFired = {};

      for (const event of trackedEvents) {
        document.addEventListener(event, () => {
          window.eventsFired[event] = true;
        });
      }
    },
    { trackedEvents: propagatedEvents },
  );

  await textarea.focus();
  // Fires 'keydown', 'keyup' and 'keypress' events
  await page.keyboard.type("Hello World");

  const firedEvents = await page.evaluate(() => {
    return window.eventsFired;
  });

  // No events are allowed to fire, object should be empty
  expect(firedEvents).toEqual({});
});

```

--------------------------------------------------------------------------------
/packages/browser-sdk/test/sse.test.ts:
--------------------------------------------------------------------------------

```typescript
import { http, HttpResponse } from "msw";
import { cleanAll, isDone } from "nock";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";

import {
  forgetAuthToken,
  getAuthToken,
  rememberAuthToken,
} from "../src/feedback/promptStorage";
import { HttpClient } from "../src/httpClient";
import { AblySSEChannel } from "../src/sse";

import { server } from "./mocks/server";
import { testLogger } from "./testLogger";

const KEY = "123";
const sseHost = "https://ssehost.com/path";
const tokenRequest = {
  keyName: "key-name",
  other: "other",
};
const tokenDetails = {
  token: "token",
  expires: new Date("2023-01-01T00:00:00.000Z").getTime(),
};

const userId = "foo";
const channel = "channel";

function createSSEChannel(callback: (message: any) => void = vi.fn()) {
  const httpClient = new HttpClient(KEY);
  return new AblySSEChannel(
    userId,
    channel,
    sseHost,
    callback,
    httpClient,
    testLogger,
  );
}

Object.defineProperty(window, "EventSource", {
  value: vi.fn().mockImplementation(() => {
    // ignore
  }),
});

vi.mock("../src/feedback/promptStorage", () => {
  return {
    rememberAuthToken: vi.fn(),
    forgetAuthToken: vi.fn(),
    getAuthToken: vi.fn(),
  };
});

function setupAuthNock(success: boolean | number) {
  server.use(
    http.get("https://front.reflag.com/feedback/prompting-auth", async () => {
      if (success === true) {
        return HttpResponse.json({ success: true, ...tokenRequest });
      } else if (success === false) {
        return HttpResponse.json({ success: false });
      } else {
        return new HttpResponse(null, {
          status: success,
        });
      }
    }),
  );
}

function setupTokenNock(success: boolean) {
  server.use(
    http.post(
      `${sseHost}/keys/${tokenRequest.keyName}/requestToken`,
      async () => {
        if (success) {
          return HttpResponse.json(tokenDetails);
        } else {
          return new HttpResponse(null, {
            status: 401,
          });
        }
      },
    ),
  );
}

describe("connection handling", () => {
  afterEach(() => {
    vi.clearAllMocks();
    vi.mocked(getAuthToken).mockReturnValue(undefined);
  });

  test("appends /sse to the sseHost", async () => {
    const sse = createSSEChannel();
    const addEventListener = vi.fn();

    vi.mocked(window.EventSource).mockReturnValue({
      addEventListener,
    } as any);

    setupAuthNock(true);
    setupTokenNock(true);

    await sse.connect();

    const lastCall = vi.mocked(window.EventSource).mock.calls[0][0];

    expect(lastCall.toString()).toMatch(`${sseHost}/sse`);
  });

  test("rejects if auth endpoint is not success", async () => {
    const sse = createSSEChannel();

    setupAuthNock(false);
    const res = await sse.connect();
    expect(res).toBeUndefined();

    expect(vi.mocked(window.EventSource)).not.toHaveBeenCalled();
  });

  test("rejects if auth endpoint is not 200", async () => {
    const sse = createSSEChannel();

    setupAuthNock(403);

    const res = await sse.connect();
    expect(res).toBeUndefined();

    expect(vi.mocked(window.EventSource)).not.toHaveBeenCalled();
  });

  test("rejects if token endpoint rejects", async () => {
    const sse = createSSEChannel();

    setupAuthNock(true);
    setupTokenNock(false);

    const res = await sse.connect();
    expect(res).toBeUndefined();

    expect(vi.mocked(window.EventSource)).not.toHaveBeenCalled();
  });

  test("obtains token, connects and subscribes, then closes", async () => {
    const addEventListener = vi.fn();
    const close = vi.fn();

    vi.mocked(window.EventSource).mockReturnValue({
      addEventListener,
      close,
    } as any);

    const sse = createSSEChannel();

    setupAuthNock(true);
    setupTokenNock(true);

    await sse.connect();

    expect(getAuthToken).toHaveBeenCalledWith(userId);
    expect(rememberAuthToken).toHaveBeenCalledWith(
      userId,
      channel,
      "token",
      new Date("2023-01-01T00:00:00.000Z"),
    );
    expect(vi.mocked(window.EventSource)).toHaveBeenCalledTimes(1);
    expect(addEventListener).toHaveBeenCalledTimes(3);
    expect(addEventListener).toHaveBeenCalledWith(
      "error",
      expect.any(Function),
    );
    expect(addEventListener).toHaveBeenCalledWith(
      "message",
      expect.any(Function),
    );
    expect(addEventListener).toHaveBeenCalledWith("open", expect.any(Function));

    expect(sse.isConnected()).toBe(true);

    sse.disconnect();

    expect(close).toHaveBeenCalledTimes(1);
    expect(sse.isConnected()).toBe(false);
  });

  test("reuses cached token", async () => {
    const sse = createSSEChannel();
    vi.mocked(getAuthToken).mockReturnValue({
      channel: channel,
      token: "cached_token",
    });

    const addEventListener = vi.fn();
    const close = vi.fn();

    vi.mocked(window.EventSource).mockReturnValue({
      addEventListener,
      close,
    } as any);

    await sse.connect();

    expect(getAuthToken).toHaveBeenCalledWith(userId);
    expect(rememberAuthToken).not.toHaveBeenCalled();

    expect(sse.isConnected()).toBe(true);
  });

  test("does not reuse cached token with wrong channel", async () => {
    const sse = createSSEChannel();

    vi.mocked(getAuthToken).mockReturnValue({
      channel: "haha",
      token: "cached_token",
    });

    const addEventListener = vi.fn();
    const close = vi.fn();

    vi.mocked(window.EventSource).mockReturnValue({
      addEventListener,
      close,
    } as any);

    setupAuthNock(true);
    setupTokenNock(true);

    await sse.connect();

    expect(rememberAuthToken).toHaveBeenCalledWith(
      userId,
      channel,
      "token",
      new Date("2023-01-01T00:00:00.000Z"),
    );
  });

  test("does not try to re-connect if already connecting", async () => {
    const sse = createSSEChannel();

    const close = vi.fn();
    vi.mocked(window.EventSource).mockReturnValue({
      addEventListener: vi.fn(),
      close,
    } as any);

    setupAuthNock(true);
    setupTokenNock(true);

    const c1 = sse.connect();
    const c2 = sse.connect();

    await c1;
    await c2;

    expect(close).toHaveBeenCalledTimes(0);
    expect(vi.mocked(window.EventSource)).toHaveBeenCalledTimes(1);
  });

  test("does not re-connect if already connected", async () => {
    const sse = createSSEChannel();

    const close = vi.fn();
    vi.mocked(window.EventSource).mockReturnValue({
      addEventListener: vi.fn(),
      close,
    } as any);

    setupAuthNock(true);
    setupTokenNock(true);

    await sse.connect();
    await sse.connect();

    expect(close).toHaveBeenCalledTimes(0);
    expect(vi.mocked(window.EventSource)).toHaveBeenCalledTimes(1);
  });

  test("disconnects only if connected", async () => {
    const sse = createSSEChannel();

    const close = vi.fn();
    vi.mocked(window.EventSource).mockReturnValue({
      close,
    } as any);

    sse.disconnect();

    expect(close).not.toHaveBeenCalled();
  });
});

describe("message handling", () => {
  beforeEach(() => {
    setupAuthNock(true);
    setupTokenNock(true);
  });

  afterEach(() => {
    expect(isDone()).toBe(true);

    vi.clearAllMocks();
    cleanAll();
  });

  test("passes message to callback", async () => {
    const callback = vi.fn();
    const sse = createSSEChannel(callback);

    let messageCallback: ((e: Event) => void) | undefined = undefined;
    const addEventListener = (event: string, cb: (e: Event) => void) => {
      if (event === "message") {
        messageCallback = cb;
      }
    };

    vi.mocked(window.EventSource).mockReturnValue({
      addEventListener,
    } as any);

    await sse.connect();

    expect(messageCallback).toBeDefined();

    messageCallback!({
      data: JSON.stringify({ data: JSON.stringify(userId) }),
    } as any);
    expect(callback).toHaveBeenCalledWith(userId);

    messageCallback!({
      data: null,
    } as any);

    messageCallback!({
      data: JSON.stringify({}),
    } as any);

    expect(callback).toHaveBeenCalledTimes(1);
  });

  test("disconnects on unknown event source errors without data", async () => {
    const sse = createSSEChannel();

    let errorCallback: ((e: Event) => Promise<void>) | undefined = undefined;
    const addEventListener = (event: string, cb: (e: Event) => void) => {
      if (event === "error") {
        errorCallback = cb as typeof errorCallback;
      }
    };

    const close = vi.fn();
    vi.mocked(window.EventSource).mockReturnValue({
      addEventListener,
      close,
    } as any);

    await sse.connect();

    expect(errorCallback).toBeDefined();

    await errorCallback!({} as any);

    expect(forgetAuthToken).not.toHaveBeenCalled();
    expect(close).toHaveBeenCalledTimes(1);
  });

  test("disconnects on unknown event source errors with data", async () => {
    const sse = createSSEChannel();
    let errorCallback: ((e: Event) => Promise<void>) | undefined = undefined;
    const addEventListener = (event: string, cb: (e: Event) => void) => {
      if (event === "error") {
        errorCallback = cb as typeof errorCallback;
      }
    };

    const close = vi.fn();
    vi.mocked(window.EventSource).mockReturnValue({
      addEventListener,
      close,
    } as any);

    await sse.connect();

    expect(errorCallback).toBeDefined();

    await errorCallback!(
      new MessageEvent("error", {
        data: JSON.stringify({ code: 400 }),
      }),
    );

    expect(close).toHaveBeenCalledTimes(1);
  });

  test("disconnects when ably reports token errors", async () => {
    const sse = createSSEChannel();

    let errorCallback: ((e: Event) => Promise<void>) | undefined = undefined;
    const addEventListener = (event: string, cb: (e: Event) => void) => {
      if (event === "error") {
        errorCallback = cb as typeof errorCallback;
      }
    };

    const close = vi.fn();
    vi.mocked(window.EventSource).mockReturnValue({
      addEventListener,
      close,
    } as any);

    await sse.connect();

    await errorCallback!(
      new MessageEvent("error", {
        data: JSON.stringify({ code: 40110 }),
      }),
    );

    expect(forgetAuthToken).toHaveBeenCalledTimes(1);
    expect(close).toHaveBeenCalled();
  });
});

describe("automatic retries", () => {
  // const nockWait = (n: nock.Scope) => {
  //   return new Promise((resolve) => {
  //     n.on("replied", () => {
  //       resolve(undefined);
  //     });
  //   });
  // };

  beforeEach(() => {
    vi.clearAllMocks();
    cleanAll();
  });

  afterEach(() => {
    expect(isDone()).toBe(true);
  });

  test("opens and connects to a channel", async () => {
    const sse = createSSEChannel();

    setupAuthNock(true);
    setupTokenNock(true);

    sse.open();

    await vi.waitFor(() =>
      sse.isConnected() ? Promise.resolve() : Promise.reject(),
    );

    expect(sse.isConnected()).toBe(true);
  });

  test("opens and connects later to a failed channel", async () => {
    const sse = createSSEChannel();

    setupAuthNock(false);

    sse.open({ retryInterval: 10 });

    await vi.waitUntil(() => !sse.isConnected());
    setupAuthNock(true);
    setupTokenNock(true);

    await vi.waitUntil(() => sse.isConnected());

    expect(sse.isConnected()).toBe(true);
    expect(sse.isActive()).toBe(true);
  });

  test("resets retry count on successful connect", async () => {
    const sse = createSSEChannel();

    // mock event source
    let errorCallback: ((e: Event) => Promise<void>) | undefined = undefined;
    const addEventListener = (event: string, cb: (e: Event) => void) => {
      if (event === "error") {
        errorCallback = cb as typeof errorCallback;
      }
    };

    const close = vi.fn();
    vi.mocked(window.EventSource).mockReturnValue({
      addEventListener,
      close,
    } as any);

    // make initial failed attempt
    setupAuthNock(false);

    sse.open({ retryInterval: 100, retryCount: 1 });

    const attempt = async () => {
      setupAuthNock(true);
      setupTokenNock(true);

      await vi.waitUntil(() => sse.isConnected());

      expect(sse.isConnected()).toBe(true);

      // simulate an error
      await errorCallback!({} as any);

      expect(sse.isConnected()).toBe(false);
    };

    await attempt();
    await attempt();
    await attempt();
  });

  test("reconnects if manually disconnected", async () => {
    const sse = createSSEChannel();

    vi.mocked(window.EventSource).mockReturnValue({
      addEventListener: vi.fn(),
      close: vi.fn(),
    } as any);

    setupAuthNock(true);
    setupTokenNock(true);

    vi.useFakeTimers();
    await sse.open({ retryInterval: 100 });

    sse.disconnect();

    setupAuthNock(true);
    setupTokenNock(true);

    vi.advanceTimersByTime(100);

    vi.useRealTimers();

    await vi.waitUntil(() => sse.isConnected());

    expect(sse.isConnected()).toBe(true);
    expect(sse.isActive()).toBe(true);
  });

  test("opens and does not connect later to a failed channel if no retries", async () => {
    const sse = createSSEChannel();

    setupAuthNock(false);

    vi.useFakeTimers();
    sse.open({
      retryCount: 0,
      retryInterval: 100,
    });

    vi.advanceTimersByTime(100);
    vi.useRealTimers();

    await vi.waitUntil(() => !sse.isActive());

    expect(sse.isActive()).toBe(false);
  });

  test("closes an open channel", async () => {
    const sse = createSSEChannel();

    setupAuthNock(true);
    setupTokenNock(true);

    const close = vi.fn();
    vi.mocked(window.EventSource).mockReturnValue({
      addEventListener: vi.fn(),
      close,
    } as any);

    sse.open();

    await vi.waitUntil(() => sse.isConnected());

    sse.close();

    expect(sse.isConnected()).toBe(false);
    expect(close).toHaveBeenCalledTimes(1);
    expect(sse.isActive()).toBe(false);
  });
});

```

--------------------------------------------------------------------------------
/packages/browser-sdk/src/flag/flags.ts:
--------------------------------------------------------------------------------

```typescript
import { deepEqual } from "fast-equals";

import { FLAG_EVENTS_PER_MIN, FLAGS_EXPIRE_MS, IS_SERVER } from "../config";
import { ReflagContext } from "../context";
import { HttpClient } from "../httpClient";
import { Logger, loggerWithPrefix } from "../logger";
import RateLimiter from "../rateLimiter";

import { FlagCache, isObject, parseAPIFlagsResponse } from "./flagCache";

/**
 * A flag fetched from the server.
 */
export type RawFlag = {
  /**
   * Flag key.
   */
  key: string;

  /**
   * Result of flag evaluation.
   * Note: does not take local overrides into account.
   */
  isEnabled: boolean;

  /**
   * If not null or undefined, the result is being overridden locally
   */
  isEnabledOverride?: boolean | null;

  /**
   * Version of targeting rules.
   */
  targetingVersion?: number;

  /**
   * Rule evaluation results.
   */
  ruleEvaluationResults?: boolean[];

  /**
   * Missing context fields.
   */
  missingContextFields?: string[];

  /**
   * Optional user-defined dynamic configuration.
   */
  config?: {
    /**
     * The key of the matched configuration value.
     */
    key: string;

    /**
     * The version of the matched configuration value.
     */
    version?: number;

    /**
     * The optional user-supplied payload data.
     */
    payload?: any;

    /**
     * The rule evaluation results.
     */
    ruleEvaluationResults?: boolean[];

    /**
     * The missing context fields.
     */
    missingContextFields?: string[];
  };
};

export type RawFlags = Record<string, RawFlag>;

export type FallbackFlagOverride =
  | {
      key: string;
      payload: any;
    }
  | true;

type FallbackFlags = Record<string, FallbackFlagOverride>;

type Config = {
  timeoutMs: number;
  staleTimeMs: number;
  staleWhileRevalidate: boolean;
  expireTimeMs: number;
  offline: boolean;
};

export const DEFAULT_FLAGS_CONFIG: Config = {
  timeoutMs: 5000,
  staleTimeMs: 0,
  staleWhileRevalidate: false,
  expireTimeMs: FLAGS_EXPIRE_MS,
  offline: false,
};

export function validateFlagsResponse(response: any) {
  if (!isObject(response)) {
    return;
  }

  if (typeof response.success !== "boolean" || !isObject(response.features)) {
    return;
  }

  const flags = parseAPIFlagsResponse(response.features);

  if (!flags) {
    return;
  }

  return {
    success: response.success,
    flags,
  };
}

export function flattenJSON(obj: Record<string, any>): Record<string, any> {
  const result: Record<string, any> = {};
  for (const key in obj) {
    if (typeof obj[key] === "object") {
      const flat = flattenJSON(obj[key]);
      for (const flatKey in flat) {
        result[`${key}.${flatKey}`] = flat[flatKey];
      }
    } else if (typeof obj[key] !== "undefined") {
      result[key] = obj[key];
    }
  }
  return result;
}

/**
 * Event representing checking the flag evaluation result
 */
export interface CheckEvent {
  /**
   * `check-is-enabled` means `isEnabled` was checked, `check-config` means `config` was checked.
   */
  action: "check-is-enabled" | "check-config";

  /**
   * Flag key.
   */
  key: string;

  /**
   * Result of flag or configuration evaluation.
   * If `action` is `check-is-enabled`, this is the result of the flag evaluation and `value` is a boolean.
   * If `action` is `check-config`, this is the result of the configuration evaluation.
   */
  value?: boolean | { key: string; payload: any };

  /**
   * Version of targeting rules.
   */
  version?: number;

  /**
   * Rule evaluation results.
   */
  ruleEvaluationResults?: boolean[];

  /**
   * Missing context fields.
   */
  missingContextFields?: string[];
}

const localStorageFetchedFlagsKey = `__reflag_fetched_flags`;
const storageOverridesKey = `__reflag_overrides`;

export type FlagOverrides = Record<string, boolean | undefined>;

type FlagsClientOptions = Partial<Config> & {
  bootstrappedFlags?: RawFlags;
  fallbackFlags?: Record<string, FallbackFlagOverride> | string[];
  cache?: FlagCache;
  rateLimiter?: RateLimiter;
};

/**
 * @internal
 */
export class FlagsClient {
  private initialized = false;
  private bootstrapped = false;

  private rateLimiter: RateLimiter;
  private readonly logger: Logger;

  private cache: FlagCache;
  private fetchedFlags: RawFlags = {};
  private flagOverrides: FlagOverrides = {};
  private flags: RawFlags = {};
  private fallbackFlags: FallbackFlags = {};

  private config: Config = DEFAULT_FLAGS_CONFIG;

  private eventTarget = new EventTarget();
  private abortController: AbortController = new AbortController();

  constructor(
    private httpClient: HttpClient,
    private context: ReflagContext,
    logger: Logger,
    {
      bootstrappedFlags,
      cache,
      rateLimiter,
      fallbackFlags,
      ...config
    }: FlagsClientOptions = {},
  ) {
    this.config = {
      ...this.config,
      ...config,
    };

    this.logger = loggerWithPrefix(logger, "[Flags]");
    this.rateLimiter =
      rateLimiter ?? new RateLimiter(FLAG_EVENTS_PER_MIN, this.logger);
    this.cache =
      cache ??
      this.setupCache(this.config.staleTimeMs, this.config.expireTimeMs);
    this.fallbackFlags = this.setupFallbackFlags(fallbackFlags);

    if (bootstrappedFlags) {
      this.bootstrapped = true;
      this.setFetchedFlags(bootstrappedFlags, false);
    }

    this.flagOverrides = this.getOverridesCache();
  }

  async initialize() {
    if (this.initialized) {
      this.logger.warn("flags client already initialized");
      return;
    }
    this.initialized = true;

    if (!this.bootstrapped) {
      this.setFetchedFlags((await this.maybeFetchFlags()) || {});
    }

    // Apply overrides and trigger update if flags have changed
    this.updateFlags();
  }

  /**
   * Stop the client.
   */
  public stop() {
    this.abortController.abort();
  }

  getFlags(): RawFlags {
    return this.flags;
  }

  getFetchedFlags(): RawFlags {
    return this.fetchedFlags;
  }

  setFetchedFlags(fetchedFlags: RawFlags, triggerEvent = true) {
    // Create a new fetched flags object making sure to clone the flags
    this.fetchedFlags = { ...fetchedFlags };
    this.warnMissingFlagContextFields(fetchedFlags);
    this.updateFlags(triggerEvent);
  }

  async setContext(context: ReflagContext) {
    this.context = context;
    this.setFetchedFlags((await this.maybeFetchFlags()) || {});
  }

  updateFlags(triggerEvent = true) {
    const updatedFlags = this.mergeFlags(this.fetchedFlags, this.flagOverrides);
    // Nothing has changed, skipping update
    if (deepEqual(this.flags, updatedFlags)) return;
    this.flags = updatedFlags;
    if (triggerEvent) this.triggerFlagsUpdated();
  }

  setFlagOverride(key: string, isEnabled: boolean | null) {
    if (!(typeof isEnabled === "boolean" || isEnabled === null)) {
      throw new Error("setFlagOverride: isEnabled must be boolean or null");
    }

    if (isEnabled === null) {
      delete this.flagOverrides[key];
    } else {
      this.flagOverrides[key] = isEnabled;
    }
    this.setOverridesCache(this.flagOverrides);

    this.updateFlags();
  }

  getFlagOverride(key: string): boolean | null {
    return this.flagOverrides[key] ?? null;
  }

  /**
   * Register a callback to be called when the flags are updated.
   * Flags are not guaranteed to have actually changed when the callback is called.
   *
   * @param callback this will be called when the flags are updated.
   * @returns a function that can be called to remove the listener
   */
  onUpdated(callback: () => void) {
    this.eventTarget.addEventListener("flagsUpdated", callback, {
      signal: this.abortController.signal,
    });
  }

  /**
   * Send a flag "check" event.
   *
   *
   * @param checkEvent - The flag to send the event for.
   * @param cb - Callback to call after the event is sent. Might be skipped if the event was rate limited.
   */
  async sendCheckEvent(checkEvent: CheckEvent, cb: () => void) {
    if (this.config.offline) {
      return;
    }

    const rateLimitKey = `check-event:${this.fetchParams().toString()}:${checkEvent.key}:${checkEvent.version}:${checkEvent.value}`;
    await this.rateLimiter.rateLimited(rateLimitKey, async () => {
      const payload = {
        action: checkEvent.action,
        key: checkEvent.key,
        targetingVersion: checkEvent.version,
        evalContext: this.context,
        evalResult: checkEvent.value,
        evalRuleResults: checkEvent.ruleEvaluationResults,
        evalMissingFields: checkEvent.missingContextFields,
      };

      this.httpClient
        .post({
          path: "features/events",
          body: payload,
        })
        .catch((e: any) => {
          this.logger.warn(`failed to send flag check event`, e);
        });

      this.logger.debug(`sent flag event`, payload);
      cb();
    });

    return checkEvent.value;
  }

  async fetchFlags(): Promise<RawFlags | undefined> {
    const params = this.fetchParams();
    try {
      const res = await this.httpClient.get({
        path: "/features/evaluated",
        timeoutMs: this.config.timeoutMs,
        params,
      });

      if (!res.ok) {
        let errorBody = null;
        try {
          errorBody = await res.json();
        } catch {
          // ignore
        }

        throw new Error(
          "unexpected response code: " +
            res.status +
            " - " +
            JSON.stringify(errorBody),
        );
      }

      const typeRes = validateFlagsResponse(await res.json());
      if (!typeRes || !typeRes.success) {
        throw new Error("unable to validate response");
      }

      return typeRes.flags;
    } catch (e) {
      this.logger.error("error fetching flags: ", e);
      return;
    }
  }

  private setOverridesCache(overrides: FlagOverrides) {
    if (IS_SERVER) return;
    try {
      localStorage.setItem(storageOverridesKey, JSON.stringify(overrides));
    } catch (error) {
      this.logger.warn(
        "storing flag overrides in localStorage failed, overrides won't persist",
        error,
      );
    }
  }

  private getOverridesCache(): FlagOverrides {
    if (IS_SERVER) return {};
    try {
      const overridesStored = localStorage.getItem(storageOverridesKey);
      const overrides = JSON.parse(overridesStored || "{}");
      if (!isObject(overrides)) throw new Error("invalid overrides");
      return overrides;
    } catch (error) {
      this.logger.warn(
        "getting flag overrides from localStorage failed",
        error,
      );
      return {};
    }
  }

  private async maybeFetchFlags(): Promise<RawFlags | undefined> {
    if (this.config.offline) {
      return;
    }

    const cacheKey = this.fetchParams().toString();
    const cachedItem = this.cache.get(cacheKey);

    if (cachedItem) {
      if (!cachedItem.stale) return cachedItem.flags;

      // serve successful stale cache if `staleWhileRevalidate` is enabled
      if (this.config.staleWhileRevalidate) {
        // re-fetch in the background, but immediately return last successful value
        this.fetchFlags()
          .then((flags) => {
            if (!flags) return;

            this.cache.set(cacheKey, {
              flags,
            });
            this.setFetchedFlags(flags);
          })
          .catch(() => {
            // we don't care about the result, we just want to re-fetch
          });
        return cachedItem.flags;
      }
    }

    // if there's no cached item or there is a stale one but `staleWhileRevalidate` is disabled
    // try fetching a new one
    const fetchedFlags = await this.fetchFlags();

    if (fetchedFlags) {
      this.cache.set(cacheKey, {
        flags: fetchedFlags,
      });
      return fetchedFlags;
    }

    if (cachedItem) {
      // fetch failed, return stale cache
      return cachedItem.flags;
    }

    // fetch failed, nothing cached => return fallbacks
    return Object.entries(this.fallbackFlags).reduce((acc, [key, override]) => {
      acc[key] = {
        key,
        isEnabled: !!override,
        config:
          typeof override === "object" && "key" in override
            ? {
                key: override.key,
                payload: override.payload,
              }
            : undefined,
      };
      return acc;
    }, {} as RawFlags);
  }

  private mergeFlags(fetchedFlags: RawFlags, overrides: FlagOverrides) {
    const mergedFlags: RawFlags = {};
    // merge fetched flags with overrides into `this.flags`
    for (const key in fetchedFlags) {
      const fetchedFlag = fetchedFlags[key];
      if (!fetchedFlag) continue;
      const isEnabledOverride = overrides[key] ?? null;
      mergedFlags[key] = { ...fetchedFlag, isEnabledOverride };
    }
    return mergedFlags;
  }

  private triggerFlagsUpdated() {
    this.eventTarget.dispatchEvent(new Event("flagsUpdated"));
  }

  private setupCache(staleTimeMs = 0, expireTimeMs = FLAGS_EXPIRE_MS) {
    return new FlagCache({
      storage: !IS_SERVER
        ? {
            get: () => localStorage.getItem(localStorageFetchedFlagsKey),
            set: (value) =>
              localStorage.setItem(localStorageFetchedFlagsKey, value),
          }
        : {
            get: () => null,
            set: () => void 0,
          },
      staleTimeMs,
      expireTimeMs,
    });
  }

  private setupFallbackFlags(
    fallbackFlags?: Record<string, FallbackFlagOverride> | string[],
  ) {
    if (Array.isArray(fallbackFlags)) {
      return fallbackFlags.reduce(
        (acc, key) => {
          acc[key] = true;
          return acc;
        },
        {} as Record<string, FallbackFlagOverride>,
      );
    } else {
      return fallbackFlags ?? {};
    }
  }

  private fetchParams() {
    const flattenedContext = flattenJSON({ context: this.context });
    const params = new URLSearchParams(flattenedContext);
    // publishableKey should be part of the cache key
    params.append("publishableKey", this.httpClient.publishableKey);

    // sort the params to ensure that the URL is the same for the same context
    params.sort();

    return params;
  }

  private warnMissingFlagContextFields(flags: RawFlags) {
    const report: Record<string, string[]> = {};
    for (const flagKey in flags) {
      const flag = flags[flagKey];
      if (flag?.missingContextFields?.length) {
        report[flag.key] = flag.missingContextFields;
      }

      if (flag?.config?.missingContextFields?.length) {
        report[`${flag.key}.config`] = flag.config.missingContextFields;
      }
    }

    if (Object.keys(report).length > 0) {
      this.rateLimiter.rateLimited(
        `flag-missing-context-fields:${this.fetchParams().toString()}`,
        () => {
          this.logger.warn(
            `flag targeting rules might not be correctly evaluated due to missing context fields.`,
            report,
          );
        },
      );
    }
  }
}

```

--------------------------------------------------------------------------------
/packages/browser-sdk/test/flags.test.ts:
--------------------------------------------------------------------------------

```typescript
import { afterAll, beforeEach, describe, expect, test, vi } from "vitest";

import { version } from "../package.json";
import { FLAGS_EXPIRE_MS } from "../src/config";
import { FlagsClient, RawFlag } from "../src/flag/flags";
import { HttpClient } from "../src/httpClient";

import { flagsResult } from "./mocks/handlers";
import { newCache, TEST_STALE_MS } from "./flagCache.test";
import { testLogger } from "./testLogger";

beforeEach(() => {
  vi.useFakeTimers();
  vi.resetAllMocks();
});

afterAll(() => {
  vi.useRealTimers();
});

function flagsClientFactory() {
  const { cache } = newCache();
  const httpClient = new HttpClient("pk", {
    baseUrl: "https://front.reflag.com",
  });

  vi.spyOn(httpClient, "get");
  vi.spyOn(httpClient, "post");

  return {
    cache,
    httpClient,
    newFlagsClient: function newFlagsClient(
      context?: Record<string, any>,
      options?: { staleWhileRevalidate?: boolean; fallbackFlags?: any },
    ) {
      return new FlagsClient(
        httpClient,
        {
          user: { id: "123" },
          company: { id: "456" },
          other: { eventId: "big-conference1" },
          ...context,
        },
        testLogger,
        {
          cache,
          ...options,
        },
      );
    },
  };
}

describe("FlagsClient", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  test("fetches flags", async () => {
    const { newFlagsClient, httpClient } = flagsClientFactory();
    const flagsClient = newFlagsClient();

    let updated = false;
    flagsClient.onUpdated(() => {
      updated = true;
    });

    await flagsClient.initialize();
    expect(flagsClient.getFlags()).toEqual(flagsResult);

    expect(updated).toBe(true);
    expect(httpClient.get).toBeCalledTimes(1);

    const calls = vi.mocked(httpClient.get).mock.calls.at(0)!;
    const { params, path, timeoutMs } = calls[0];

    const paramsObj = Object.fromEntries(new URLSearchParams(params));
    expect(paramsObj).toEqual({
      "reflag-sdk-version": "browser-sdk/" + version,
      "context.user.id": "123",
      "context.company.id": "456",
      "context.other.eventId": "big-conference1",
      publishableKey: "pk",
    });

    expect(path).toEqual("/features/evaluated");
    expect(timeoutMs).toEqual(5000);
  });

  test("warns about missing context fields", async () => {
    const { newFlagsClient } = flagsClientFactory();
    const flagsClient = newFlagsClient();

    await flagsClient.initialize();

    expect(testLogger.warn).toHaveBeenCalledTimes(1);
    expect(testLogger.warn).toHaveBeenCalledWith(
      "[Flags] flag targeting rules might not be correctly evaluated due to missing context fields.",
      {
        flagA: ["field1", "field2"],
        "flagB.config": ["field3"],
      },
    );

    vi.advanceTimersByTime(TEST_STALE_MS + 1);

    expect(testLogger.warn).toHaveBeenCalledTimes(1);
    vi.advanceTimersByTime(60 * 1000);
    await flagsClient.initialize();
    expect(testLogger.warn).toHaveBeenCalledTimes(2);
  });

  test("ignores undefined context", async () => {
    const { newFlagsClient, httpClient } = flagsClientFactory();
    const flagsClient = newFlagsClient({
      user: undefined,
      company: undefined,
      other: undefined,
    });
    await flagsClient.initialize();
    expect(flagsClient.getFlags()).toEqual(flagsResult);

    expect(httpClient.get).toBeCalledTimes(1);
    const calls = vi.mocked(httpClient.get).mock.calls.at(0);
    const { params, path, timeoutMs } = calls![0];

    const paramsObj = Object.fromEntries(new URLSearchParams(params));
    expect(paramsObj).toEqual({
      "reflag-sdk-version": "browser-sdk/" + version,
      publishableKey: "pk",
    });

    expect(path).toEqual("/features/evaluated");
    expect(timeoutMs).toEqual(5000);
  });

  test("return fallback flags on failure (string list)", async () => {
    const { newFlagsClient, httpClient } = flagsClientFactory();

    vi.mocked(httpClient.get).mockRejectedValue(
      new Error("Failed to fetch flags"),
    );

    const flagsClient = newFlagsClient(undefined, {
      fallbackFlags: ["huddle"],
    });

    await flagsClient.initialize();
    expect(flagsClient.getFlags()).toStrictEqual({
      huddle: {
        isEnabled: true,
        config: undefined,
        key: "huddle",
        isEnabledOverride: null,
      },
    });
  });

  test("return fallback flags on failure (record)", async () => {
    const { newFlagsClient, httpClient } = flagsClientFactory();

    vi.mocked(httpClient.get).mockRejectedValue(
      new Error("Failed to fetch flags"),
    );
    const flagsClient = newFlagsClient(undefined, {
      fallbackFlags: {
        huddle: {
          key: "john",
          payload: { something: "else" },
        },
        zoom: true,
      },
    });

    await flagsClient.initialize();
    expect(flagsClient.getFlags()).toStrictEqual({
      huddle: {
        isEnabled: true,
        config: { key: "john", payload: { something: "else" } },
        key: "huddle",
        isEnabledOverride: null,
      },
      zoom: {
        isEnabled: true,
        config: undefined,
        key: "zoom",
        isEnabledOverride: null,
      },
    });
  });

  test("caches response", async () => {
    const { newFlagsClient, httpClient } = flagsClientFactory();

    const flagsClient1 = newFlagsClient();
    await flagsClient1.initialize();

    expect(httpClient.get).toBeCalledTimes(1);

    const flagsClient2 = newFlagsClient();
    await flagsClient2.initialize();

    const flags = flagsClient2.getFlags();

    expect(flags).toEqual(flagsResult);
    expect(httpClient.get).toBeCalledTimes(1);
  });

  test("use cache when unable to fetch flags", async () => {
    const { newFlagsClient, httpClient } = flagsClientFactory();
    const flagsClient = newFlagsClient({ staleWhileRevalidate: false });
    await flagsClient.initialize(); // cache them initially

    vi.mocked(httpClient.get).mockRejectedValue(
      new Error("Failed to fetch flags"),
    );
    expect(httpClient.get).toBeCalledTimes(1);

    vi.advanceTimersByTime(TEST_STALE_MS + 1);

    // fail this time
    await flagsClient.fetchFlags();
    expect(httpClient.get).toBeCalledTimes(2);

    const staleFlags = flagsClient.getFlags();
    expect(staleFlags).toEqual(flagsResult);
  });

  test("stale-while-revalidate should cache but start new fetch", async () => {
    const response = {
      success: true,
      features: {
        flagB: {
          isEnabled: true,
          key: "flagB",
          targetingVersion: 1,
        } satisfies RawFlag,
      },
    };

    const { newFlagsClient, httpClient } = flagsClientFactory();

    vi.mocked(httpClient.get).mockResolvedValue({
      status: 200,
      ok: true,
      json: function () {
        return Promise.resolve(response);
      },
    } as Response);

    const client = newFlagsClient({
      staleWhileRevalidate: true,
    });
    expect(httpClient.get).toHaveBeenCalledTimes(0);

    await client.initialize();
    expect(client.getFlags()).toEqual({
      flagB: {
        isEnabled: true,
        key: "flagB",
        targetingVersion: 1,
        isEnabledOverride: null,
      } satisfies RawFlag,
    });

    expect(httpClient.get).toHaveBeenCalledTimes(1);
    const client2 = newFlagsClient({
      staleWhileRevalidate: true,
    });

    // change the response so we can validate that we'll serve the stale cache
    vi.mocked(httpClient.get).mockResolvedValue({
      status: 200,
      ok: true,
      json: () =>
        Promise.resolve({
          success: true,
          features: {
            flagA: {
              isEnabled: true,
              key: "flagA",
              targetingVersion: 1,
            },
          },
        }),
    } as Response);

    vi.advanceTimersByTime(TEST_STALE_MS + 1);

    await client2.initialize();

    // new fetch was fired in the background
    expect(httpClient.get).toHaveBeenCalledTimes(2);

    await vi.waitFor(() =>
      expect(client2.getFlags()).toEqual({
        flagA: {
          isEnabled: true,
          targetingVersion: 1,
          key: "flagA",
          isEnabledOverride: null,
        } satisfies RawFlag,
      }),
    );
  });

  test("expires cache eventually", async () => {
    // change the response so we can validate that we'll serve the stale cache
    const { newFlagsClient, httpClient } = flagsClientFactory();
    const client = newFlagsClient();
    await client.initialize();
    const a = client.getFlags();

    vi.advanceTimersByTime(FLAGS_EXPIRE_MS + 1);
    vi.mocked(httpClient.get).mockResolvedValue({
      status: 200,
      ok: true,
      json: () =>
        Promise.resolve({
          success: true,
          features: {
            flagB: { isEnabled: true, key: "flagB" },
          },
        }),
    } as Response);
    const client2 = newFlagsClient();
    await client2.initialize();

    const b = client2.getFlags();

    expect(httpClient.get).toHaveBeenCalledTimes(2);
    expect(a).not.toEqual(b);
  });

  test("handled overrides", async () => {
    // change the response so we can validate that we'll serve the stale cache
    const { newFlagsClient } = flagsClientFactory();
    // localStorage.clear();
    const client = newFlagsClient();
    await client.initialize();

    let updated = false;
    client.onUpdated(() => {
      updated = true;
    });

    expect(client.getFlags().flagA.isEnabled).toBe(true);
    expect(client.getFlags().flagA.isEnabledOverride).toBe(null);

    expect(updated).toBe(false);

    client.setFlagOverride("flagA", false);

    expect(updated).toBe(true);
    expect(client.getFlags().flagA.isEnabled).toBe(true);
    expect(client.getFlags().flagA.isEnabledOverride).toBe(false);
  });

  test("ignores overrides for flags not returned by API", async () => {
    // change the response so we can validate that we'll serve the stale cache
    const { newFlagsClient } = flagsClientFactory();

    // localStorage.clear();
    const client = newFlagsClient(undefined);
    await client.initialize();

    let updated = false;
    client.onUpdated(() => {
      updated = true;
    });

    expect(client.getFlags().flagB.isEnabled).toBe(true);
    expect(client.getFlags().flagB.isEnabledOverride).toBe(null);

    // Setting an override for a flag that doesn't exist in fetched flags
    // should not trigger an update since the merged flags don't change
    client.setFlagOverride("flagC", true);

    expect(updated).toBe(false);
    expect(client.getFlags().flagC).toBeUndefined();
  });

  describe("pre-fetched flags", () => {
    test("should have flags available when bootstrapped flags are provided in constructor", () => {
      const { httpClient } = flagsClientFactory();
      const preFetchedFlags = {
        testFlag: {
          key: "testFlag",
          isEnabled: true,
          targetingVersion: 1,
        },
        configFlag: {
          key: "configFlag",
          isEnabled: false,
          targetingVersion: 2,
          config: {
            key: "config1",
            version: 1,
            payload: { value: "test" },
          },
        },
      };

      const flagsClient = new FlagsClient(
        httpClient,
        {
          user: { id: "123" },
          company: { id: "456" },
          other: { eventId: "big-conference1" },
        },
        testLogger,
        {
          bootstrappedFlags: preFetchedFlags,
        },
      );

      // Should be bootstrapped but not initialized until initialize() is called
      expect(flagsClient["bootstrapped"]).toBe(true);
      expect(flagsClient["initialized"]).toBe(false);

      // Should have the flags available even before initialize()
      expect(flagsClient.getFlags()).toEqual({
        testFlag: {
          key: "testFlag",
          isEnabled: true,
          targetingVersion: 1,
          isEnabledOverride: null,
        },
        configFlag: {
          key: "configFlag",
          isEnabled: false,
          targetingVersion: 2,
          config: {
            key: "config1",
            version: 1,
            payload: { value: "test" },
          },
          isEnabledOverride: null,
        },
      });
    });

    test("should skip fetching when already initialized with pre-fetched flags", async () => {
      const { httpClient } = flagsClientFactory();
      vi.spyOn(httpClient, "get");

      const preFetchedFlags = {
        testFlag: {
          key: "testFlag",
          isEnabled: true,
          targetingVersion: 1,
        },
      };

      const flagsClient = new FlagsClient(
        httpClient,
        {
          user: { id: "123" },
          company: { id: "456" },
          other: { eventId: "big-conference1" },
        },
        testLogger,
        {
          bootstrappedFlags: preFetchedFlags,
        },
      );

      // Call initialize() after flags are already provided
      await flagsClient.initialize();

      // Should not have made any HTTP requests since already initialized
      expect(httpClient.get).not.toHaveBeenCalled();

      // Should still have the flags available
      expect(flagsClient.getFlags()).toEqual({
        testFlag: {
          key: "testFlag",
          isEnabled: true,
          targetingVersion: 1,
          isEnabledOverride: null,
        },
      });
    });

    test("should trigger onUpdated when pre-fetched flags are set", async () => {
      const { httpClient } = flagsClientFactory();
      const preFetchedFlags = {
        testFlag: {
          key: "testFlag",
          isEnabled: true,
          targetingVersion: 1,
        },
      };

      const flagsClient = new FlagsClient(
        httpClient,
        {
          user: { id: "123" },
          company: { id: "456" },
          other: { eventId: "big-conference1" },
        },
        testLogger,
        {
          bootstrappedFlags: preFetchedFlags,
        },
      );

      let updateTriggered = false;
      flagsClient.onUpdated(() => {
        updateTriggered = true;
      });

      // Trigger the flags updated event by setting context (which should still fetch)
      await flagsClient.setContext({
        user: { id: "456" },
        company: { id: "789" },
        other: { eventId: "other-conference" },
      });

      expect(updateTriggered).toBe(true);
    });

    test("should work with fallback flags when initialization fails", async () => {
      const { httpClient } = flagsClientFactory();
      vi.spyOn(httpClient, "get").mockRejectedValue(
        new Error("Failed to fetch flags"),
      );

      const preFetchedFlags = {
        testFlag: {
          key: "testFlag",
          isEnabled: true,
          targetingVersion: 1,
        },
      };

      const flagsClient = new FlagsClient(
        httpClient,
        {
          user: { id: "123" },
          company: { id: "456" },
          other: { eventId: "big-conference1" },
        },
        testLogger,
        {
          bootstrappedFlags: preFetchedFlags,
          fallbackFlags: ["fallbackFlag"],
        },
      );

      // Should be bootstrapped but not initialized until initialize() is called
      expect(flagsClient["bootstrapped"]).toBe(true);
      expect(flagsClient["initialized"]).toBe(false);
      expect(flagsClient.getFlags()).toEqual({
        testFlag: {
          key: "testFlag",
          isEnabled: true,
          targetingVersion: 1,
          isEnabledOverride: null,
        },
      });

      // Calling initialize should not fetch since already bootstrapped
      await flagsClient.initialize();
      expect(httpClient.get).not.toHaveBeenCalled();
      expect(flagsClient["initialized"]).toBe(true);
    });
  });
});

```
Page 4/7FirstPrevNextLast