This is page 5 of 9. Use http://codebase.md/bucketco/bucket-javascript-sdk?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .editorconfig
├── .gitattributes
├── .github
│ └── workflows
│ ├── package-ci.yml
│ └── publish.yml
├── .gitignore
├── .nvmrc
├── .prettierignore
├── .vscode
│ ├── extensions.json
│ └── settings.json
├── .yarnrc.yml
├── docs.sh
├── lerna.json
├── LICENSE
├── package.json
├── packages
│ ├── browser-sdk
│ │ ├── .prettierignore
│ │ ├── eslint.config.js
│ │ ├── example
│ │ │ ├── feedback
│ │ │ │ ├── feedback.html
│ │ │ │ └── Feedback.jsx
│ │ │ └── typescript
│ │ │ ├── app.ts
│ │ │ └── index.html
│ │ ├── FEEDBACK.md
│ │ ├── index.html
│ │ ├── package.json
│ │ ├── playwright.config.ts
│ │ ├── postcss.config.js
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── client.ts
│ │ │ ├── config.ts
│ │ │ ├── context.ts
│ │ │ ├── feedback
│ │ │ │ ├── feedback.ts
│ │ │ │ ├── prompts.ts
│ │ │ │ ├── promptStorage.ts
│ │ │ │ └── ui
│ │ │ │ ├── Button.css
│ │ │ │ ├── Button.tsx
│ │ │ │ ├── config
│ │ │ │ │ └── defaultTranslations.tsx
│ │ │ │ ├── css.d.ts
│ │ │ │ ├── FeedbackDialog.css
│ │ │ │ ├── FeedbackDialog.tsx
│ │ │ │ ├── FeedbackForm.css
│ │ │ │ ├── FeedbackForm.tsx
│ │ │ │ ├── hooks
│ │ │ │ │ └── useTimer.ts
│ │ │ │ ├── index.css
│ │ │ │ ├── index.ts
│ │ │ │ ├── Plug.tsx
│ │ │ │ ├── RadialProgress.css
│ │ │ │ ├── RadialProgress.tsx
│ │ │ │ ├── StarRating.css
│ │ │ │ ├── StarRating.tsx
│ │ │ │ └── types.ts
│ │ │ ├── flag
│ │ │ │ ├── flagCache.ts
│ │ │ │ └── flags.ts
│ │ │ ├── hooksManager.ts
│ │ │ ├── httpClient.ts
│ │ │ ├── index.ts
│ │ │ ├── logger.ts
│ │ │ ├── rateLimiter.ts
│ │ │ ├── sse.ts
│ │ │ ├── toolbar
│ │ │ │ ├── Flags.css
│ │ │ │ ├── Flags.tsx
│ │ │ │ ├── index.css
│ │ │ │ ├── index.ts
│ │ │ │ ├── Switch.css
│ │ │ │ ├── Switch.tsx
│ │ │ │ ├── Toolbar.css
│ │ │ │ └── Toolbar.tsx
│ │ │ └── ui
│ │ │ ├── constants.ts
│ │ │ ├── Dialog.css
│ │ │ ├── Dialog.tsx
│ │ │ ├── icons
│ │ │ │ ├── Check.tsx
│ │ │ │ ├── CheckCircle.tsx
│ │ │ │ ├── Close.tsx
│ │ │ │ ├── Dissatisfied.tsx
│ │ │ │ ├── Logo.tsx
│ │ │ │ ├── Neutral.tsx
│ │ │ │ ├── Satisfied.tsx
│ │ │ │ ├── VeryDissatisfied.tsx
│ │ │ │ └── VerySatisfied.tsx
│ │ │ ├── packages
│ │ │ │ └── floating-ui-preact-dom
│ │ │ │ ├── arrow.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── README.md
│ │ │ │ ├── types.ts
│ │ │ │ ├── useFloating.ts
│ │ │ │ └── utils
│ │ │ │ ├── deepEqual.ts
│ │ │ │ ├── getDPR.ts
│ │ │ │ ├── roundByDPR.ts
│ │ │ │ └── useLatestRef.ts
│ │ │ ├── types.ts
│ │ │ └── utils.ts
│ │ ├── test
│ │ │ ├── client.test.ts
│ │ │ ├── e2e
│ │ │ │ ├── acceptance.browser.spec.ts
│ │ │ │ ├── empty.html
│ │ │ │ ├── feedback-widget.browser.spec.ts
│ │ │ │ └── give-feedback-button.html
│ │ │ ├── flagCache.test.ts
│ │ │ ├── flags.test.ts
│ │ │ ├── hooksManager.test.ts
│ │ │ ├── httpClient.test.ts
│ │ │ ├── init.test.ts
│ │ │ ├── mocks
│ │ │ │ ├── handlers.ts
│ │ │ │ └── server.ts
│ │ │ ├── prompts.test.ts
│ │ │ ├── promptStorage.test.ts
│ │ │ ├── rateLimiter.test.ts
│ │ │ ├── sse.test.ts
│ │ │ ├── testLogger.ts
│ │ │ └── usage.test.ts
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ ├── typedoc.json
│ │ ├── vite.config.mjs
│ │ ├── vite.e2e.config.js
│ │ └── vitest.setup.ts
│ ├── cli
│ │ ├── .prettierignore
│ │ ├── commands
│ │ │ ├── apps.ts
│ │ │ ├── auth.ts
│ │ │ ├── flags.ts
│ │ │ ├── init.ts
│ │ │ ├── mcp.ts
│ │ │ ├── new.ts
│ │ │ └── rules.ts
│ │ ├── eslint.config.js
│ │ ├── index.ts
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── schema.json
│ │ ├── services
│ │ │ ├── bootstrap.ts
│ │ │ ├── flags.ts
│ │ │ ├── mcp.ts
│ │ │ └── rules.ts
│ │ ├── stores
│ │ │ ├── auth.ts
│ │ │ └── config.ts
│ │ ├── test
│ │ │ └── json.test.ts
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ ├── utils
│ │ │ ├── auth.ts
│ │ │ ├── commander.ts
│ │ │ ├── constants.ts
│ │ │ ├── errors.ts
│ │ │ ├── file.ts
│ │ │ ├── gen.ts
│ │ │ ├── json.ts
│ │ │ ├── options.ts
│ │ │ ├── schemas.ts
│ │ │ ├── types.ts
│ │ │ ├── urls.ts
│ │ │ └── version.ts
│ │ └── vite.config.js
│ ├── eslint-config
│ │ ├── base.js
│ │ └── package.json
│ ├── flag-evaluation
│ │ ├── .prettierignore
│ │ ├── eslint.config.js
│ │ ├── jest.config.js
│ │ ├── package.json
│ │ ├── src
│ │ │ └── index.ts
│ │ ├── test
│ │ │ └── index.test.ts
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ └── tsconfig.json
│ ├── node-sdk
│ │ ├── .prettierignore
│ │ ├── docs
│ │ │ ├── type-check-failed.png
│ │ │ └── type-check-payload-failed.png
│ │ ├── eslint.config.js
│ │ ├── examples
│ │ │ ├── cloudflare-worker
│ │ │ │ ├── .gitignore
│ │ │ │ ├── .prettierignore
│ │ │ │ ├── .vscode
│ │ │ │ │ └── settings.json
│ │ │ │ ├── package.json
│ │ │ │ ├── README.md
│ │ │ │ ├── src
│ │ │ │ │ └── index.ts
│ │ │ │ ├── tsconfig.json
│ │ │ │ ├── vitest.config.mts
│ │ │ │ ├── worker-configuration.d.ts
│ │ │ │ ├── wrangler.jsonc
│ │ │ │ └── yarn.lock
│ │ │ └── express
│ │ │ ├── app.test.ts
│ │ │ ├── app.ts
│ │ │ ├── bucket.ts
│ │ │ ├── bucketConfig.json
│ │ │ ├── package.json
│ │ │ ├── README.md
│ │ │ ├── serve.ts
│ │ │ ├── tsconfig.json
│ │ │ └── yarn.lock
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── batch-buffer.ts
│ │ │ ├── client.ts
│ │ │ ├── config.ts
│ │ │ ├── edgeClient.ts
│ │ │ ├── fetch-http-client.ts
│ │ │ ├── flusher.ts
│ │ │ ├── index.ts
│ │ │ ├── inRequestCache.ts
│ │ │ ├── periodicallyUpdatingCache.ts
│ │ │ ├── rate-limiter.ts
│ │ │ ├── types.ts
│ │ │ └── utils.ts
│ │ ├── test
│ │ │ ├── batch-buffer.test.ts
│ │ │ ├── client.test.ts
│ │ │ ├── config.test.ts
│ │ │ ├── fetch-http-client.test.ts
│ │ │ ├── flusher.test.ts
│ │ │ ├── inRequestCache.test.ts
│ │ │ ├── periodicallyUpdatingCache.test.ts
│ │ │ ├── rate-limiter.test.ts
│ │ │ ├── testConfig.json
│ │ │ └── utils.test.ts
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ ├── typedoc.json
│ │ └── vite.config.js
│ ├── openfeature-browser-provider
│ │ ├── .prettierignore
│ │ ├── eslint.config.js
│ │ ├── example
│ │ │ ├── .eslintrc.json
│ │ │ ├── .gitignore
│ │ │ ├── app
│ │ │ │ ├── featureManagement.ts
│ │ │ │ ├── globals.css
│ │ │ │ ├── layout.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── components
│ │ │ │ ├── Context.tsx
│ │ │ │ ├── HuddleFeature.tsx
│ │ │ │ └── OpenFeatureProvider.tsx
│ │ │ ├── next.config.mjs
│ │ │ ├── package.json
│ │ │ ├── postcss.config.mjs
│ │ │ ├── README.md
│ │ │ ├── tailwind.config.ts
│ │ │ └── tsconfig.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── index.test.ts
│ │ │ └── index.ts
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ └── vite.config.js
│ ├── openfeature-node-provider
│ │ ├── .prettierignore
│ │ ├── eslint.config.js
│ │ ├── example
│ │ │ ├── app.ts
│ │ │ ├── package.json
│ │ │ ├── README.md
│ │ │ ├── reflag.ts
│ │ │ ├── serve.ts
│ │ │ ├── tsconfig.json
│ │ │ └── yarn.lock
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── index.test.ts
│ │ │ └── index.ts
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ └── vite.config.js
│ ├── react-sdk
│ │ ├── .prettierignore
│ │ ├── dev
│ │ │ ├── .env
│ │ │ ├── nextjs-bootstrap-demo
│ │ │ │ ├── .eslintrc.json
│ │ │ │ ├── .gitignore
│ │ │ │ ├── app
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── favicon.ico
│ │ │ │ │ ├── globals.css
│ │ │ │ │ ├── layout.tsx
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── components
│ │ │ │ │ └── Flags.tsx
│ │ │ │ ├── next.config.mjs
│ │ │ │ ├── package.json
│ │ │ │ ├── postcss.config.mjs
│ │ │ │ ├── public
│ │ │ │ │ ├── next.svg
│ │ │ │ │ └── vercel.svg
│ │ │ │ ├── README.md
│ │ │ │ ├── tailwind.config.ts
│ │ │ │ └── tsconfig.json
│ │ │ ├── nextjs-flag-demo
│ │ │ │ ├── .eslintrc.json
│ │ │ │ ├── .gitignore
│ │ │ │ ├── app
│ │ │ │ │ ├── favicon.ico
│ │ │ │ │ ├── globals.css
│ │ │ │ │ ├── layout.tsx
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── components
│ │ │ │ │ ├── Flags.tsx
│ │ │ │ │ └── Providers.tsx
│ │ │ │ ├── next.config.mjs
│ │ │ │ ├── package.json
│ │ │ │ ├── postcss.config.mjs
│ │ │ │ ├── public
│ │ │ │ │ ├── next.svg
│ │ │ │ │ └── vercel.svg
│ │ │ │ ├── README.md
│ │ │ │ ├── tailwind.config.ts
│ │ │ │ └── tsconfig.json
│ │ │ └── plain
│ │ │ ├── app.tsx
│ │ │ ├── index.html
│ │ │ ├── index.tsx
│ │ │ ├── tsconfig.json
│ │ │ └── vite-env.d.ts
│ │ ├── eslint.config.js
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ └── index.tsx
│ │ ├── test
│ │ │ └── usage.test.tsx
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ ├── typedoc.json
│ │ └── vite.config.mjs
│ ├── tsconfig
│ │ ├── library.json
│ │ └── package.json
│ └── vue-sdk
│ ├── .prettierignore
│ ├── dev
│ │ └── plain
│ │ ├── App.vue
│ │ ├── components
│ │ │ ├── Events.vue
│ │ │ ├── FlagsList.vue
│ │ │ ├── MissingKeyMessage.vue
│ │ │ ├── RequestFeedback.vue
│ │ │ ├── Section.vue
│ │ │ ├── StartHuddlesButton.vue
│ │ │ └── Track.vue
│ │ ├── env.d.ts
│ │ ├── index.html
│ │ └── index.ts
│ ├── eslint.config.js
│ ├── package.json
│ ├── README.md
│ ├── src
│ │ ├── hooks.ts
│ │ ├── index.ts
│ │ ├── ReflagBootstrappedProvider.vue
│ │ ├── ReflagClientProvider.vue
│ │ ├── ReflagProvider.vue
│ │ ├── types.ts
│ │ ├── version.ts
│ │ └── vue.d.ts
│ ├── test
│ │ └── usage.test.ts
│ ├── tsconfig.build.json
│ ├── tsconfig.eslint.json
│ ├── tsconfig.json
│ ├── typedoc.json
│ └── vite.config.mjs
├── README.md
├── typedoc.json
├── vitest.workspace.js
└── yarn.lock
```
# Files
--------------------------------------------------------------------------------
/packages/browser-sdk/src/ui/Dialog.tsx:
--------------------------------------------------------------------------------
```typescript
1 | import { MiddlewareData, Placement } from "@floating-ui/dom";
2 | import { Fragment, FunctionComponent, h, Ref } from "preact";
3 | import { useCallback, useEffect, useRef, useState } from "preact/hooks";
4 |
5 | import {
6 | arrow,
7 | autoUpdate,
8 | flip,
9 | offset,
10 | shift,
11 | useFloating,
12 | } from "./packages/floating-ui-preact-dom";
13 | import styles from "./Dialog.css?inline";
14 | import { Position } from "./types";
15 | import { parseUnanchoredPosition } from "./utils";
16 |
17 | type CssPosition = Partial<
18 | Record<"top" | "left" | "right" | "bottom", number | string>
19 | >;
20 |
21 | export interface OpenDialogOptions {
22 | /**
23 | * Control the placement and behavior of the dialog.
24 | */
25 | position: Position;
26 |
27 | strategy?: "fixed" | "absolute";
28 |
29 | isOpen: boolean;
30 | close: () => void;
31 | onDismiss?: () => void;
32 |
33 | containerId: string;
34 |
35 | showArrow?: boolean;
36 |
37 | children?: preact.ComponentChildren;
38 | }
39 |
40 | export function useDialog({
41 | onClose,
42 | onOpen,
43 | initialValue = false,
44 | }: {
45 | onClose?: () => void;
46 | onOpen?: () => void;
47 | initialValue?: boolean;
48 | } = {}) {
49 | const [isOpen, setIsOpen] = useState<boolean>(initialValue);
50 |
51 | const open = useCallback(() => {
52 | setIsOpen(true);
53 | onOpen?.();
54 | }, [onOpen]);
55 | const close = useCallback(() => {
56 | setIsOpen(false);
57 | onClose?.();
58 | }, [onClose]);
59 | const toggle = useCallback(() => {
60 | if (isOpen) onClose?.();
61 | else onOpen?.();
62 | setIsOpen((prev) => !prev);
63 | }, [isOpen, onClose, onOpen]);
64 |
65 | return {
66 | isOpen,
67 | open,
68 | close,
69 | toggle,
70 | };
71 | }
72 |
73 | export const Dialog: FunctionComponent<OpenDialogOptions> = ({
74 | position,
75 | isOpen,
76 | close,
77 | onDismiss,
78 | containerId,
79 | strategy,
80 | children,
81 | showArrow = true,
82 | }) => {
83 | const arrowRef = useRef<HTMLDivElement>(null);
84 | const dialogRef = useRef<HTMLDialogElement>(null);
85 |
86 | const anchor = position.type === "POPOVER" ? position.anchor : null;
87 | const placement =
88 | position.type === "POPOVER" ? position.placement : undefined;
89 |
90 | const {
91 | refs,
92 | floatingStyles,
93 | middlewareData,
94 | placement: actualPlacement,
95 | } = useFloating({
96 | elements: {
97 | reference: anchor,
98 | },
99 | strategy,
100 | transform: false,
101 | placement,
102 | whileElementsMounted: autoUpdate,
103 | middleware: [
104 | flip({
105 | padding: 10,
106 | mainAxis: true,
107 | crossAxis: true,
108 | }),
109 | shift(),
110 | offset(8),
111 | arrow({
112 | element: arrowRef,
113 | }),
114 | ],
115 | });
116 |
117 | let unanchoredPosition: CssPosition = {};
118 | if (position.type === "DIALOG") {
119 | unanchoredPosition = parseUnanchoredPosition(position);
120 | }
121 |
122 | const dismiss = useCallback(() => {
123 | close();
124 | onDismiss?.();
125 | }, [close, onDismiss]);
126 |
127 | useEffect(() => {
128 | // Only enable 'quick dismiss' for popovers
129 | if (position.type === "MODAL" || position.type === "DIALOG") return;
130 |
131 | const escapeHandler = (e: KeyboardEvent) => {
132 | if (e.key == "Escape") {
133 | dismiss();
134 | }
135 | };
136 |
137 | const clickOutsideHandler = (e: MouseEvent) => {
138 | if (
139 | !(e.target instanceof Element) ||
140 | !e.target.closest(`#${containerId}`)
141 | ) {
142 | dismiss();
143 | }
144 | };
145 |
146 | const observer = new MutationObserver((mutations) => {
147 | if (position.anchor === null) return;
148 |
149 | mutations.forEach((mutation) => {
150 | const removedNodes = Array.from(mutation.removedNodes);
151 | const hasBeenRemoved = removedNodes.some((node) => {
152 | return node === position.anchor || node.contains(position.anchor);
153 | });
154 |
155 | if (hasBeenRemoved) {
156 | close();
157 | }
158 | });
159 | });
160 |
161 | window.addEventListener("mousedown", clickOutsideHandler);
162 | window.addEventListener("keydown", escapeHandler);
163 | observer.observe(document.body, {
164 | subtree: true,
165 | childList: true,
166 | });
167 |
168 | return () => {
169 | window.removeEventListener("mousedown", clickOutsideHandler);
170 | window.removeEventListener("keydown", escapeHandler);
171 | observer.disconnect();
172 | };
173 | // eslint-disable-next-line react-hooks/exhaustive-deps -- anchor only exists in popover
174 | }, [position.type, close, (position as any).anchor, dismiss, containerId]);
175 |
176 | function setDiagRef(node: HTMLDialogElement | null) {
177 | refs.setFloating(node);
178 | dialogRef.current = node;
179 | }
180 |
181 | useEffect(() => {
182 | if (!dialogRef.current) return;
183 | if (isOpen && !dialogRef.current.hasAttribute("open")) {
184 | dialogRef.current[position.type === "MODAL" ? "showModal" : "show"]();
185 | }
186 | if (!isOpen && dialogRef.current.hasAttribute("open")) {
187 | dialogRef.current.close();
188 | }
189 | }, [dialogRef, isOpen, position.type]);
190 |
191 | const classes = [
192 | "dialog",
193 | position.type === "MODAL"
194 | ? "modal"
195 | : position.type === "POPOVER"
196 | ? "anchored"
197 | : `unanchored unanchored-${position.placement}`,
198 | actualPlacement,
199 | ].join(" ");
200 |
201 | return (
202 | <>
203 | <style dangerouslySetInnerHTML={{ __html: styles }} />
204 | <dialog
205 | ref={setDiagRef}
206 | class={classes}
207 | style={anchor ? floatingStyles : unanchoredPosition}
208 | >
209 | {children && <Fragment>{children}</Fragment>}
210 |
211 | {anchor && showArrow && (
212 | <DialogArrow
213 | arrowData={middlewareData?.arrow}
214 | arrowRef={arrowRef}
215 | placement={actualPlacement}
216 | />
217 | )}
218 | </dialog>
219 | </>
220 | );
221 | };
222 |
223 | function DialogArrow({
224 | arrowData,
225 | arrowRef,
226 | placement,
227 | }: {
228 | arrowData: MiddlewareData["arrow"];
229 | arrowRef: Ref<HTMLDivElement>;
230 | placement: Placement;
231 | }) {
232 | const { x: arrowX, y: arrowY } = arrowData ?? {};
233 |
234 | const staticSide =
235 | {
236 | top: "bottom",
237 | right: "left",
238 | bottom: "top",
239 | left: "right",
240 | }[placement.split("-")[0]] || "bottom";
241 |
242 | const arrowStyles = {
243 | left: arrowX != null ? `${arrowX}px` : "",
244 | top: arrowY != null ? `${arrowY}px` : "",
245 | right: "",
246 | bottom: "",
247 | [staticSide]: "-4px",
248 | };
249 | return (
250 | <div
251 | ref={arrowRef}
252 | class={["arrow", placement].join(" ")}
253 | style={arrowStyles}
254 | />
255 | );
256 | }
257 |
258 | export function DialogHeader({
259 | children,
260 | innerRef,
261 | }: {
262 | children: preact.ComponentChildren;
263 | innerRef?: Ref<HTMLElement>;
264 | }) {
265 | return (
266 | <header ref={innerRef} class="dialog-header">
267 | {children}
268 | </header>
269 | );
270 | }
271 |
272 | export function DialogContent({
273 | children,
274 | innerRef,
275 | }: {
276 | children: preact.ComponentChildren;
277 | innerRef?: Ref<HTMLDivElement>;
278 | }) {
279 | return (
280 | <div ref={innerRef} class="dialog-content">
281 | {children}
282 | </div>
283 | );
284 | }
285 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/src/toolbar/Toolbar.tsx:
--------------------------------------------------------------------------------
```typescript
1 | import { h } from "preact";
2 | import {
3 | useCallback,
4 | useEffect,
5 | useMemo,
6 | useRef,
7 | useState,
8 | } from "preact/hooks";
9 |
10 | import { ReflagClient } from "../client";
11 | import { IS_SERVER } from "../config";
12 | import { toolbarContainerId } from "../ui/constants";
13 | import { Dialog, DialogContent, DialogHeader, useDialog } from "../ui/Dialog";
14 | import { Logo } from "../ui/icons/Logo";
15 | import { ToolbarPosition } from "../ui/types";
16 | import { parseUnanchoredPosition } from "../ui/utils";
17 |
18 | import { FlagSearch, FlagsTable } from "./Flags";
19 | import styles from "./index.css?inline";
20 |
21 | const TOOLBAR_HIDE_KEY = "reflag-toolbar-hidden";
22 |
23 | export type FlagItem = {
24 | flagKey: string;
25 | isEnabled: boolean;
26 | isEnabledOverride: boolean | null;
27 | };
28 |
29 | type Flag = {
30 | flagKey: string;
31 | isEnabled: boolean;
32 | isEnabledOverride: boolean | null;
33 | };
34 |
35 | export default function Toolbar({
36 | reflagClient,
37 | position,
38 | }: {
39 | reflagClient: ReflagClient;
40 | position: ToolbarPosition;
41 | }) {
42 | const toggleToolbarRef = useRef<HTMLDivElement>(null);
43 | const dialogContentRef = useRef<HTMLDivElement>(null);
44 | const [flags, setFlags] = useState<Flag[]>([]);
45 |
46 | const wasHidden =
47 | !IS_SERVER && sessionStorage.getItem(TOOLBAR_HIDE_KEY) === "true";
48 | const [isHidden, setIsHidden] = useState(wasHidden);
49 |
50 | const updateFlags = useCallback(() => {
51 | const rawFlags = reflagClient.getFlags();
52 | setFlags(
53 | Object.values(rawFlags)
54 | .filter((f) => f !== undefined)
55 | .map(
56 | (flag) =>
57 | ({
58 | flagKey: flag.key,
59 | isEnabledOverride: flag.isEnabledOverride ?? null,
60 | isEnabled: flag.isEnabled,
61 | }) satisfies FlagItem,
62 | ),
63 | );
64 | }, [reflagClient]);
65 |
66 | const hasAnyOverrides = useMemo(() => {
67 | return flags.some((f) => f.isEnabledOverride !== null);
68 | }, [flags]);
69 |
70 | useEffect(() => {
71 | updateFlags();
72 | reflagClient.on("flagsUpdated", updateFlags);
73 | }, [reflagClient, updateFlags]);
74 |
75 | const [search, setSearch] = useState<string | null>(null);
76 | const onSearch = (val: string) => {
77 | setSearch(val === "" ? null : val);
78 | dialogContentRef.current?.scrollTo({ top: 0 });
79 | };
80 |
81 | const sortedFlags = [...flags].sort((a, b) =>
82 | a.flagKey.localeCompare(b.flagKey),
83 | );
84 |
85 | const appBaseUrl = reflagClient.getConfig().appBaseUrl;
86 |
87 | const { isOpen, close, toggle } = useDialog();
88 |
89 | const hideToolbar = useCallback(() => {
90 | if (IS_SERVER) return;
91 | sessionStorage.setItem(TOOLBAR_HIDE_KEY, "true");
92 | setIsHidden(true);
93 | close();
94 | }, [close]);
95 |
96 | if (isHidden) {
97 | return null;
98 | }
99 |
100 | return (
101 | <div class="toolbar">
102 | <style dangerouslySetInnerHTML={{ __html: styles }} />
103 | <ToolbarToggle
104 | hasAnyOverrides={hasAnyOverrides}
105 | innerRef={toggleToolbarRef}
106 | isOpen={isOpen}
107 | position={position}
108 | onClick={toggle}
109 | />
110 | <Dialog
111 | close={close}
112 | containerId={toolbarContainerId}
113 | isOpen={isOpen}
114 | position={{
115 | type: "POPOVER",
116 | anchor: toggleToolbarRef.current,
117 | placement: "top-start",
118 | }}
119 | showArrow={false}
120 | strategy="fixed"
121 | >
122 | <DialogHeader>
123 | <FlagSearch onSearch={onSearch} />
124 | <a
125 | class="toolbar-header-button"
126 | data-tooltip="Open Reflag app"
127 | href={`${appBaseUrl}/env-current`}
128 | >
129 | <svg
130 | width="15"
131 | height="15"
132 | xmlns="http://www.w3.org/2000/svg"
133 | viewBox="0 0 24 24"
134 | fill="currentColor"
135 | >
136 | <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" />
137 | </svg>
138 | </a>
139 | <button
140 | class="toolbar-header-button"
141 | onClick={hideToolbar}
142 | data-tooltip="Hide toolbar this session"
143 | >
144 | <svg
145 | width="15"
146 | height="15"
147 | xmlns="http://www.w3.org/2000/svg"
148 | viewBox="0 0 24 24"
149 | fill="currentColor"
150 | >
151 | <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" />
152 | </svg>
153 | </button>
154 | </DialogHeader>
155 | <DialogContent innerRef={dialogContentRef}>
156 | <FlagsTable
157 | appBaseUrl={appBaseUrl}
158 | flags={sortedFlags}
159 | searchQuery={search?.toLocaleLowerCase() ?? null}
160 | setIsEnabledOverride={(flagKey, isEnabled) =>
161 | reflagClient.getFlag(flagKey).setIsEnabledOverride(isEnabled)
162 | }
163 | />
164 | </DialogContent>
165 | </Dialog>
166 | </div>
167 | );
168 | }
169 |
170 | function ToolbarToggle({
171 | isOpen,
172 | position,
173 | onClick,
174 | innerRef,
175 | hasAnyOverrides,
176 | }: {
177 | isOpen: boolean;
178 | position: ToolbarPosition;
179 | onClick: () => void;
180 | innerRef: React.RefObject<HTMLDivElement>;
181 | hasAnyOverrides: boolean;
182 | children?: preact.VNode;
183 | }) {
184 | const offsets = parseUnanchoredPosition(position);
185 |
186 | const toggleClasses = ["toolbar-toggle", isOpen ? "open" : undefined].join(
187 | " ",
188 | );
189 |
190 | const indicatorClasses = [
191 | "override-indicator",
192 | hasAnyOverrides ? "show" : undefined,
193 | ].join(" ");
194 |
195 | return (
196 | <div ref={innerRef} class={toggleClasses} style={offsets} onClick={onClick}>
197 | <div class={indicatorClasses} />
198 | <Logo height="13px" width="13px" />
199 | </div>
200 | );
201 | }
202 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/src/sse.ts:
--------------------------------------------------------------------------------
```typescript
1 | import {
2 | forgetAuthToken,
3 | getAuthToken,
4 | rememberAuthToken,
5 | } from "./feedback/promptStorage";
6 | import { HttpClient } from "./httpClient";
7 | import { Logger, loggerWithPrefix } from "./logger";
8 |
9 | interface AblyTokenDetails {
10 | token: string;
11 | expires: number;
12 | }
13 |
14 | interface AblyTokenRequest {
15 | keyName: string;
16 | }
17 |
18 | const ABLY_TOKEN_ERROR_MIN = 40000;
19 | const ABLY_TOKEN_ERROR_MAX = 49999;
20 |
21 | export class AblySSEChannel {
22 | private isOpen: boolean = false;
23 | private eventSource: EventSource | null = null;
24 | private retryInterval: ReturnType<typeof setInterval> | null = null;
25 | private logger: Logger;
26 |
27 | constructor(
28 | private userId: string,
29 | private channel: string,
30 | private sseBaseUrl: string,
31 | private messageHandler: (message: any) => void,
32 | private httpClient: HttpClient,
33 | logger: Logger,
34 | ) {
35 | this.logger = loggerWithPrefix(logger, "[SSE]");
36 |
37 | if (!this.sseBaseUrl.endsWith("/")) {
38 | this.sseBaseUrl += "/";
39 | }
40 | }
41 |
42 | private async refreshTokenRequest() {
43 | const params = new URLSearchParams({ userId: this.userId });
44 | const res = await this.httpClient.get({
45 | path: `/feedback/prompting-auth`,
46 | params,
47 | });
48 |
49 | if (res.ok) {
50 | const body = await res.json();
51 | if (body.success) {
52 | delete body.success;
53 | const tokenRequest: AblyTokenRequest = body;
54 |
55 | this.logger.debug("obtained new token request", tokenRequest);
56 | return tokenRequest;
57 | }
58 | }
59 |
60 | this.logger.error("server did not release a token request", res);
61 | return;
62 | }
63 |
64 | private async refreshToken() {
65 | const cached = getAuthToken(this.userId);
66 | if (cached && cached.channel === this.channel) {
67 | this.logger.debug("using existing token", cached.channel, cached.token);
68 | return cached.token;
69 | }
70 |
71 | const tokenRequest = await this.refreshTokenRequest();
72 | if (!tokenRequest) {
73 | return;
74 | }
75 |
76 | const url = new URL(
77 | `keys/${encodeURIComponent(tokenRequest.keyName)}/requestToken`,
78 | this.sseBaseUrl,
79 | );
80 |
81 | const res = await fetch(url, {
82 | method: "post",
83 | headers: {
84 | "Content-Type": "application/json",
85 | },
86 | body: JSON.stringify(tokenRequest),
87 | });
88 |
89 | if (res.ok) {
90 | const details: AblyTokenDetails = await res.json();
91 | this.logger.debug("obtained new token", details);
92 |
93 | rememberAuthToken(
94 | this.userId,
95 | this.channel,
96 | details.token,
97 | new Date(details.expires),
98 | );
99 | return details.token;
100 | }
101 |
102 | this.logger.error("server did not release a token");
103 |
104 | return;
105 | }
106 |
107 | private async onError(e: Event) {
108 | if (e instanceof MessageEvent) {
109 | let errorCode: number | undefined;
110 |
111 | try {
112 | const errorPayload = JSON.parse(e.data);
113 | errorCode = errorPayload?.code && Number(errorPayload.code);
114 | } catch (error: any) {
115 | this.logger.warn("received unparsable error message", error, e);
116 | }
117 |
118 | if (
119 | errorCode &&
120 | errorCode >= ABLY_TOKEN_ERROR_MIN &&
121 | errorCode <= ABLY_TOKEN_ERROR_MAX
122 | ) {
123 | this.logger.warn("event source token expired, refresh required");
124 | forgetAuthToken(this.userId);
125 | }
126 | } else {
127 | const connectionState = (e as any)?.target?.readyState;
128 |
129 | if (connectionState === 2) {
130 | this.logger.debug("event source connection closed", e);
131 | } else if (connectionState === 1) {
132 | this.logger.warn("event source connection failed to open", e);
133 | } else {
134 | this.logger.warn("event source unexpected error occurred", e);
135 | }
136 | }
137 |
138 | this.disconnect();
139 | }
140 |
141 | private onMessage(e: MessageEvent) {
142 | let payload: any;
143 |
144 | try {
145 | if (e.data) {
146 | const message = JSON.parse(e.data);
147 | if (message.data) {
148 | payload = JSON.parse(message.data);
149 | }
150 | }
151 | } catch (error: any) {
152 | this.logger.warn("received unparsable message", error, e);
153 | return;
154 | }
155 |
156 | if (payload) {
157 | this.logger.debug("received message", payload);
158 |
159 | try {
160 | this.messageHandler(payload);
161 | } catch (error: any) {
162 | this.logger.warn("failed to handle message", error, payload);
163 | }
164 |
165 | return;
166 | }
167 |
168 | this.logger.warn("received invalid message", e);
169 | }
170 |
171 | private onOpen(e: Event) {
172 | this.logger.debug("event source connection opened", e);
173 | }
174 |
175 | public async connect() {
176 | if (this.isOpen) {
177 | this.logger.warn("channel connection already open");
178 | return;
179 | }
180 |
181 | this.isOpen = true;
182 | try {
183 | const token = await this.refreshToken();
184 |
185 | if (!token) return;
186 |
187 | const url = new URL("sse", this.sseBaseUrl);
188 | url.searchParams.append("v", "1.2");
189 | url.searchParams.append("accessToken", token);
190 | url.searchParams.append("channels", this.channel);
191 | url.searchParams.append("rewind", "1");
192 |
193 | this.eventSource = new EventSource(url);
194 |
195 | this.eventSource.addEventListener("error", (e) => this.onError(e));
196 | this.eventSource.addEventListener("open", (e) => this.onOpen(e));
197 | this.eventSource.addEventListener("message", (m) => this.onMessage(m));
198 |
199 | this.logger.debug("channel connection opened");
200 | } finally {
201 | this.isOpen = !!this.eventSource;
202 | }
203 | }
204 |
205 | public disconnect() {
206 | if (!this.isOpen) {
207 | this.logger.warn("channel connection already closed");
208 | return;
209 | }
210 |
211 | if (this.eventSource) {
212 | this.eventSource.close();
213 | this.eventSource = null;
214 |
215 | this.logger.debug("channel connection closed");
216 | }
217 |
218 | this.isOpen = false;
219 | }
220 |
221 | public open(options?: { retryInterval?: number; retryCount?: number }) {
222 | const retryInterval = options?.retryInterval ?? 1000 * 30;
223 | const retryCount = options?.retryCount ?? 3;
224 | let retriesRemaining = retryCount;
225 |
226 | const tryConnect = async () => {
227 | try {
228 | await this.connect();
229 | retriesRemaining = retryCount;
230 | } catch (e) {
231 | if (retriesRemaining > 0) {
232 | this.logger.warn(
233 | `failed to connect, ${retriesRemaining} retries remaining`,
234 | e,
235 | );
236 | } else {
237 | this.logger.warn(`failed to connect, no retries remaining`, e);
238 | }
239 | }
240 | };
241 |
242 | void tryConnect();
243 |
244 | this.retryInterval = setInterval(() => {
245 | if (!this.isConnected() && this.retryInterval) {
246 | if (retriesRemaining <= 0) {
247 | clearInterval(this.retryInterval);
248 | this.retryInterval = null;
249 | return;
250 | }
251 |
252 | retriesRemaining--;
253 | void tryConnect();
254 | }
255 | }, retryInterval);
256 | }
257 |
258 | public close() {
259 | if (this.retryInterval) {
260 | clearInterval(this.retryInterval);
261 | this.retryInterval = null;
262 | }
263 |
264 | this.disconnect();
265 | }
266 |
267 | public isActive() {
268 | return !!this.retryInterval;
269 | }
270 |
271 | public isConnected() {
272 | return this.isOpen && !!this.eventSource;
273 | }
274 | }
275 |
276 | export function openAblySSEChannel({
277 | userId,
278 | channel,
279 | callback,
280 | httpClient,
281 | sseBaseUrl,
282 | logger,
283 | }: {
284 | userId: string;
285 | channel: string;
286 | callback: (req: object) => void;
287 | httpClient: HttpClient;
288 | logger: Logger;
289 | sseBaseUrl: string;
290 | }) {
291 | const sse = new AblySSEChannel(
292 | userId,
293 | channel,
294 | sseBaseUrl,
295 | callback,
296 | httpClient,
297 | logger,
298 | );
299 |
300 | sse.open();
301 |
302 | return sse;
303 | }
304 |
305 | export function closeAblySSEChannel(channel: AblySSEChannel) {
306 | channel.close();
307 | }
308 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/test/client.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { beforeEach, describe, expect, it, vi } from "vitest";
2 |
3 | import { ReflagClient } from "../src/client";
4 | import { FlagsClient } from "../src/flag/flags";
5 | import { HttpClient } from "../src/httpClient";
6 |
7 | import { flagsResult } from "./mocks/handlers";
8 |
9 | describe("ReflagClient", () => {
10 | let client: ReflagClient;
11 | const httpClientPost = vi.spyOn(HttpClient.prototype as any, "post");
12 | const httpClientGet = vi.spyOn(HttpClient.prototype as any, "get");
13 |
14 | const flagClientSetContext = vi.spyOn(FlagsClient.prototype, "setContext");
15 |
16 | beforeEach(() => {
17 | client = new ReflagClient({
18 | publishableKey: "test-key",
19 | user: { id: "user1" },
20 | company: { id: "company1" },
21 | });
22 |
23 | vi.clearAllMocks();
24 | });
25 |
26 | describe("updateUser", () => {
27 | it("should update the user context", async () => {
28 | // and send new user data and trigger flag update
29 | const updatedUser = { name: "New User" };
30 |
31 | await client.updateUser(updatedUser);
32 |
33 | expect(client["context"].user).toEqual({ id: "user1", ...updatedUser });
34 | expect(httpClientPost).toHaveBeenCalledWith({
35 | path: "/user",
36 | body: {
37 | userId: "user1",
38 | attributes: { name: updatedUser.name },
39 | },
40 | });
41 | expect(flagClientSetContext).toHaveBeenCalledWith(client["context"]);
42 | });
43 | });
44 |
45 | describe("updateCompany", () => {
46 | it("should update the company context", async () => {
47 | // send new company data and trigger flag update
48 | const updatedCompany = { name: "New Company" };
49 |
50 | await client.updateCompany(updatedCompany);
51 |
52 | expect(client["context"].company).toEqual({
53 | id: "company1",
54 | ...updatedCompany,
55 | });
56 | expect(httpClientPost).toHaveBeenCalledWith({
57 | path: "/company",
58 | body: {
59 | userId: "user1",
60 | companyId: "company1",
61 | attributes: { name: updatedCompany.name },
62 | },
63 | });
64 | expect(flagClientSetContext).toHaveBeenCalledWith(client["context"]);
65 | });
66 | });
67 |
68 | describe("getFlag", () => {
69 | it("takes overrides into account", async () => {
70 | await client.initialize();
71 | expect(flagsResult["flagA"].isEnabled).toBe(true);
72 | expect(client.getFlag("flagA").isEnabled).toBe(true);
73 | client.getFlag("flagA").setIsEnabledOverride(false);
74 | expect(client.getFlag("flagA").isEnabled).toBe(false);
75 | });
76 | });
77 |
78 | describe("hooks integration", () => {
79 | it("on adds hooks appropriately, off removes them", async () => {
80 | const trackHook = vi.fn();
81 | const userHook = vi.fn();
82 | const companyHook = vi.fn();
83 | const checkHook = vi.fn();
84 | const flagsUpdated = vi.fn();
85 |
86 | client.on("track", trackHook);
87 | client.on("user", userHook);
88 | client.on("company", companyHook);
89 | client.on("check", checkHook);
90 | client.on("flagsUpdated", flagsUpdated);
91 |
92 | await client.track("test-event");
93 | expect(trackHook).toHaveBeenCalledWith({
94 | eventName: "test-event",
95 | attributes: undefined,
96 | user: client["context"].user,
97 | company: client["context"].company,
98 | });
99 |
100 | await client["user"]();
101 | expect(userHook).toHaveBeenCalledWith(client["context"].user);
102 |
103 | await client["company"]();
104 | expect(companyHook).toHaveBeenCalledWith(client["context"].company);
105 |
106 | // eslint-disable-next-line @typescript-eslint/no-unused-expressions -- special getter triggering event
107 | client.getFlag("flagA").isEnabled;
108 | expect(checkHook).toHaveBeenCalled();
109 |
110 | checkHook.mockReset();
111 |
112 | // eslint-disable-next-line @typescript-eslint/no-unused-expressions -- special getter triggering event
113 | client.getFlag("flagA").config;
114 | expect(checkHook).toHaveBeenCalled();
115 |
116 | expect(flagsUpdated).not.toHaveBeenCalled();
117 | await client.updateOtherContext({ key: "value" });
118 | expect(flagsUpdated).toHaveBeenCalled();
119 |
120 | // Remove hooks
121 | client.off("track", trackHook);
122 | client.off("user", userHook);
123 | client.off("company", companyHook);
124 | client.off("check", checkHook);
125 | client.off("flagsUpdated", flagsUpdated);
126 |
127 | // Reset mocks
128 | trackHook.mockReset();
129 | userHook.mockReset();
130 | companyHook.mockReset();
131 | checkHook.mockReset();
132 | flagsUpdated.mockReset();
133 |
134 | // Trigger events again
135 | await client.track("test-event");
136 | await client["user"]();
137 | await client["company"]();
138 | // eslint-disable-next-line @typescript-eslint/no-unused-expressions -- special getter triggering event
139 | client.getFlag("flagA").isEnabled;
140 | // eslint-disable-next-line @typescript-eslint/no-unused-expressions -- special getter triggering event
141 | client.getFlag("flagA").config;
142 | await client.updateOtherContext({ key: "value" });
143 |
144 | // Ensure hooks are not called
145 | expect(trackHook).not.toHaveBeenCalled();
146 | expect(userHook).not.toHaveBeenCalled();
147 | expect(companyHook).not.toHaveBeenCalled();
148 | expect(checkHook).not.toHaveBeenCalled();
149 | expect(flagsUpdated).not.toHaveBeenCalled();
150 | });
151 | });
152 |
153 | describe("offline mode", () => {
154 | it("should not make HTTP calls when offline", async () => {
155 | client = new ReflagClient({
156 | publishableKey: "test-key",
157 | user: { id: "user1" },
158 | company: { id: "company1" },
159 | offline: true,
160 | feedback: { enableAutoFeedback: true },
161 | });
162 |
163 | await client.initialize();
164 | await client.track("offline-event");
165 | await client.feedback({ flagKey: "flagA", score: 5 });
166 | await client.updateUser({ name: "New User" });
167 | await client.updateCompany({ name: "New Company" });
168 | await client.stop();
169 |
170 | expect(httpClientPost).not.toHaveBeenCalled();
171 | expect(httpClientGet).not.toHaveBeenCalled();
172 | });
173 | });
174 |
175 | describe("bootstrap parameter", () => {
176 | const flagsClientInitialize = vi.spyOn(FlagsClient.prototype, "initialize");
177 |
178 | beforeEach(() => {
179 | flagsClientInitialize.mockClear();
180 | });
181 |
182 | it("should use pre-fetched flags and skip initialization when flags are provided", async () => {
183 | const preFetchedFlags = {
184 | testFlag: {
185 | key: "testFlag",
186 | isEnabled: true,
187 | targetingVersion: 1,
188 | },
189 | };
190 |
191 | // Create a spy to monitor maybeFetchFlags which should not be called if already initialized
192 | const maybeFetchFlags = vi.spyOn(
193 | FlagsClient.prototype as any,
194 | "maybeFetchFlags",
195 | );
196 |
197 | client = new ReflagClient({
198 | publishableKey: "test-key",
199 | user: { id: "user1" },
200 | company: { id: "company1" },
201 | bootstrappedFlags: preFetchedFlags,
202 | feedback: { enableAutoFeedback: false }, // Disable to avoid HTTP calls
203 | });
204 |
205 | // FlagsClient should be bootstrapped but not initialized in constructor when flags are provided
206 | expect(client["flagsClient"]["bootstrapped"]).toBe(true);
207 | expect(client["flagsClient"]["initialized"]).toBe(false);
208 | expect(client.getFlags()).toEqual({
209 | testFlag: {
210 | key: "testFlag",
211 | isEnabled: true,
212 | targetingVersion: 1,
213 | isEnabledOverride: null,
214 | },
215 | });
216 |
217 | maybeFetchFlags.mockClear();
218 |
219 | await client.initialize();
220 |
221 | // After initialize, flagsClient should be properly initialized
222 | expect(client["flagsClient"]["initialized"]).toBe(true);
223 |
224 | // maybeFetchFlags should not be called since flagsClient is already bootstrapped
225 | expect(maybeFetchFlags).not.toHaveBeenCalled();
226 | });
227 | });
228 | });
229 |
```
--------------------------------------------------------------------------------
/packages/react-sdk/dev/plain/app.tsx:
--------------------------------------------------------------------------------
```typescript
1 | import React, { useState } from "react";
2 |
3 | import {
4 | FlagKey,
5 | ReflagProvider,
6 | useFlag,
7 | useRequestFeedback,
8 | useTrack,
9 | useUpdateCompany,
10 | useUpdateOtherContext,
11 | useUpdateUser,
12 | useClient,
13 | ReflagBootstrappedProvider,
14 | RawFlags,
15 | useOnEvent,
16 | } from "../../src";
17 |
18 | // Extending the Flags interface to define the available features
19 | declare module "../../src" {
20 | interface Flags {
21 | huddles: {
22 | config: {
23 | payload: {
24 | maxParticipants: number;
25 | };
26 | };
27 | };
28 | }
29 | }
30 |
31 | const publishableKey = import.meta.env.VITE_PUBLISHABLE_KEY || "";
32 | const apiBaseUrl = import.meta.env.VITE_REFLAG_API_BASE_URL;
33 |
34 | function HuddlesFeature() {
35 | // Type safe feature
36 | const feature = useFlag("huddles");
37 | return (
38 | <div>
39 | <h2>Huddles feature</h2>
40 | <pre>
41 | <code>{JSON.stringify(feature, null, 2)}</code>
42 | </pre>
43 | </div>
44 | );
45 | }
46 |
47 | // Initial context
48 | const initialUser = {
49 | id: "demo-user",
50 | email: "[email protected]",
51 | };
52 | const initialCompany = {
53 | id: "demo-company",
54 | name: "Demo Company",
55 | };
56 | const initialOtherContext = {
57 | test: "test",
58 | };
59 |
60 | function UpdateContext() {
61 | const updateUser = useUpdateUser();
62 | const updateCompany = useUpdateCompany();
63 | const updateOtherContext = useUpdateOtherContext();
64 |
65 | const [newUser, setNewUser] = useState(JSON.stringify(initialUser));
66 | const [newCompany, setNewCompany] = useState(JSON.stringify(initialCompany));
67 | const [newOtherContext, setNewOtherContext] = useState(
68 | JSON.stringify(initialOtherContext),
69 | );
70 |
71 | return (
72 | <div>
73 | <h2>Update context</h2>
74 | <div>
75 | Update the context by editing the textarea. User/company IDs cannot be
76 | changed here.
77 | </div>
78 | <table>
79 | <tbody>
80 | <tr>
81 | <td>
82 | <textarea
83 | value={newCompany}
84 | onChange={(e) => setNewCompany(e.target.value)}
85 | ></textarea>
86 | </td>
87 | <td>
88 | <button onClick={() => updateCompany(JSON.parse(newCompany))}>
89 | Update company
90 | </button>
91 | </td>
92 | </tr>
93 | <tr>
94 | <td>
95 | <textarea
96 | value={newUser}
97 | onChange={(e) => setNewUser(e.target.value)}
98 | ></textarea>
99 | </td>
100 | <td>
101 | <button onClick={() => updateUser(JSON.parse(newUser))}>
102 | Update user
103 | </button>
104 | </td>
105 | </tr>
106 | <tr>
107 | <td>
108 | <textarea
109 | value={newOtherContext}
110 | onChange={(e) => setNewOtherContext(e.target.value)}
111 | ></textarea>
112 | </td>
113 | <td>
114 | <button
115 | onClick={() => updateOtherContext(JSON.parse(newOtherContext))}
116 | >
117 | Update other context
118 | </button>
119 | </td>
120 | </tr>
121 | </tbody>
122 | </table>
123 | </div>
124 | );
125 | }
126 |
127 | function SendEvent() {
128 | // Send track event
129 | const [eventName, setEventName] = useState("event1");
130 | const track = useTrack();
131 | return (
132 | <div>
133 | <h2>Send event</h2>
134 | <input
135 | onChange={(e) => setEventName(e.target.value)}
136 | type="text"
137 | placeholder="Event name"
138 | value={eventName}
139 | />
140 | <button
141 | onClick={() => {
142 | track(eventName);
143 | }}
144 | >
145 | Send event
146 | </button>
147 | </div>
148 | );
149 | }
150 |
151 | function Feedback() {
152 | const requestFeedback = useRequestFeedback();
153 |
154 | return (
155 | <div>
156 | <h2>Feedback</h2>
157 | <button
158 | onClick={(e) =>
159 | requestFeedback({
160 | title: "How do you like Huddles?",
161 | flagKey: "huddles",
162 | position: {
163 | type: "POPOVER",
164 | anchor: e.currentTarget as HTMLElement,
165 | },
166 | })
167 | }
168 | >
169 | Request feedback
170 | </button>
171 | </div>
172 | );
173 | }
174 |
175 | // App.tsx
176 | function Demos() {
177 | return (
178 | <main>
179 | <h1>React SDK</h1>
180 |
181 | <HuddlesFeature />
182 |
183 | <h2>Feature opt-in</h2>
184 | <div>
185 | Create a <code>huddle</code> feature and set a rule:{" "}
186 | <code>optin-huddles IS TRUE</code>. Hit the checkbox below to opt-in/out
187 | of the feature.
188 | </div>
189 | <FeatureOptIn flagKey={"huddles"} featureName={"Huddles"} />
190 |
191 | <UpdateContext />
192 | <Feedback />
193 | <SendEvent />
194 | <CustomToolbar />
195 | </main>
196 | );
197 | }
198 |
199 | function FeatureOptIn({
200 | flagKey,
201 | featureName,
202 | }: {
203 | flagKey: FlagKey;
204 | featureName: string;
205 | }) {
206 | const updateUser = useUpdateUser();
207 | const [sendingUpdate, setSendingUpdate] = useState(false);
208 | const { isEnabled } = useFlag(flagKey);
209 |
210 | return (
211 | <div>
212 | <label htmlFor="huddlesOptIn">Opt-in to {featureName} feature</label>
213 | <input
214 | disabled={sendingUpdate}
215 | id="huddlesOptIn"
216 | type="checkbox"
217 | checked={isEnabled}
218 | onChange={() => {
219 | setSendingUpdate(true);
220 | updateUser({
221 | [`optin-${flagKey}`]: isEnabled ? "false" : "true",
222 | })?.then(() => {
223 | setSendingUpdate(false);
224 | });
225 | }}
226 | />
227 | </div>
228 | );
229 | }
230 |
231 | function CustomToolbar() {
232 | const client = useClient();
233 | const [flags, setFlags] = useState<RawFlags>(client.getFlags() ?? {});
234 |
235 | useOnEvent("flagsUpdated", () => {
236 | setFlags(client.getFlags() ?? {});
237 | });
238 |
239 | return (
240 | <div>
241 | <h2>Custom toolbar</h2>
242 | <p>This toolbar is static and won't update when flags are fetched.</p>
243 | <ul>
244 | {Object.entries(flags).map(([flagKey, feature]) => (
245 | <li key={flagKey}>
246 | {flagKey} -
247 | {(feature.isEnabledOverride ?? feature.isEnabled)
248 | ? "Enabled"
249 | : "Disabled"}{" "}
250 | {feature.isEnabledOverride !== null && (
251 | <button
252 | onClick={() => {
253 | client.getFlag(flagKey).setIsEnabledOverride(null);
254 | }}
255 | >
256 | Reset
257 | </button>
258 | )}
259 | <input
260 | checked={feature.isEnabledOverride ?? feature.isEnabled}
261 | type="checkbox"
262 | onChange={(e) => {
263 | // this uses slightly simplified logic compared to the Reflag Toolbar
264 | client
265 | .getFlag(flagKey)
266 | .setIsEnabledOverride(e.target.checked ?? false);
267 | }}
268 | />
269 | </li>
270 | ))}
271 | </ul>
272 | </div>
273 | );
274 | }
275 |
276 | export function App() {
277 | const bootstrapped = new URLSearchParams(window.location.search).get(
278 | "bootstrapped",
279 | );
280 |
281 | if (bootstrapped) {
282 | return (
283 | <ReflagBootstrappedProvider
284 | publishableKey={publishableKey}
285 | flags={{
286 | context: {
287 | user: initialUser,
288 | company: initialCompany,
289 | other: initialOtherContext,
290 | },
291 | flags: {
292 | huddles: {
293 | key: "huddles",
294 | isEnabled: true,
295 | },
296 | },
297 | }}
298 | apiBaseUrl={apiBaseUrl}
299 | >
300 | {!publishableKey && (
301 | <div>
302 | No publishable key set. Please set the VITE_PUBLISHABLE_KEY
303 | environment variable.
304 | </div>
305 | )}
306 | <Demos />
307 | </ReflagBootstrappedProvider>
308 | );
309 | }
310 |
311 | return (
312 | <ReflagProvider
313 | publishableKey={publishableKey}
314 | context={{
315 | user: initialUser,
316 | company: initialCompany,
317 | other: initialOtherContext,
318 | }}
319 | apiBaseUrl={apiBaseUrl}
320 | >
321 | {!publishableKey && (
322 | <div>
323 | No publishable key set. Please set the VITE_PUBLISHABLE_KEY
324 | environment variable.
325 | </div>
326 | )}
327 | <Demos />
328 | </ReflagProvider>
329 | );
330 | }
331 |
```
--------------------------------------------------------------------------------
/packages/cli/services/rules.ts:
--------------------------------------------------------------------------------
```typescript
1 | export function getCursorRules() {
2 | return `
3 | ---
4 | description: Guidelines for implementing flagging using Reflag feature management service
5 | globs: "**/*.ts, **/*.tsx, **/*.js, **/*.jsx"
6 | ---
7 |
8 | ${rules}
9 | `.trim();
10 | }
11 |
12 | export function getCopilotInstructions() {
13 | return rules;
14 | }
15 |
16 | const rules = /* markdown */ `
17 | # Reflag Flag Management Service for LLMs
18 |
19 | 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.
20 |
21 | ## Follow Official Documentation
22 |
23 | - Refer to [Reflag's official documentation](mdc:https:/docs.reflag.com) for implementation details.
24 | - Adhere to Reflag's recommended patterns for each framework.
25 |
26 | ## Reflag SDK Usage
27 |
28 | - Configure \`ReflagProvider\` or \`ReflagClient\` properly at application entry points.
29 | - Leverage Reflag CLI for generating type-safe feature definitions.
30 | - Write clean, type-safe code when applying Reflag flags.
31 | - Follow established patterns in the project.
32 |
33 | ## Flag Implementation
34 |
35 | - Create reusable hooks and utilities for consistent feature management.
36 | - Write clear comments for usage and checks of a flag.
37 | - Properly handle feature loading states to prevent UI flashing.
38 | - Implement proper error fallbacks when flag services are unavailable.
39 |
40 | ## Flag Targeting
41 |
42 | - Use release stages to manage feature rollout (for example, development, staging, production).
43 | - Use targeting modes effectively:
44 | - \`none\`: Flag is disabled for all targets.
45 | - \`some\`: Flag is enabled only for specified targets.
46 | - \`everyone\`: Flag is enabled for all targets.
47 | - Target features to specific users, companies, or segments.
48 |
49 | ## Analytics and Feedback
50 |
51 | - Track feature usage with Reflag analytics.
52 | - Collect user feedback on features.
53 | - Monitor feature adoption and health.
54 |
55 | ## Common Concepts
56 |
57 | ### Targeting Rules
58 |
59 | 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.
60 |
61 | ### Flag Stages
62 |
63 | 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.
64 |
65 | Release stages are useful tools when a standard release workflow is used in your organization.
66 |
67 | Predefined stages:
68 |
69 | - In development
70 | - Internal
71 | - Beta
72 | - General Availability
73 |
74 | ### Segments
75 |
76 | 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.
77 |
78 | #### Segment filters can be constructed using any combination of the following rules:
79 |
80 | - company attributes
81 | - user feature access
82 | - feature metrics
83 | - other segments
84 |
85 | ### Integrations
86 |
87 | Connect Reflag with your existing tools:
88 |
89 | - Linear
90 | - Datadog
91 | - Segment
92 | - PostHog
93 | - Amplitude
94 | - Mixpanel
95 | - AWS S3
96 | - Slack
97 |
98 | ## React SDK Implementation
99 |
100 | ### Installation
101 |
102 | \`\`\`bash
103 | npm i @reflag/react-sdk
104 | \`\`\`
105 |
106 | ### Key Features
107 |
108 | - Flag toggling with fine-grained targeting
109 | - User feedback collection
110 | - Flag usage tracking
111 | - Remote configuration
112 | - Type-safe feature management
113 |
114 | ### Basic Setup
115 |
116 | 1. Add the \`ReflagProvider\` to wrap your application:
117 |
118 | \`\`\`jsx
119 | import { ReflagProvider } from "@reflag/react-sdk";
120 |
121 | <ReflagProvider
122 | publishableKey="{YOUR_PUBLISHABLE_KEY}"
123 | company={{ id: "acme_inc", plan: "pro" }}
124 | user={{ id: "john_doe" }}
125 | >
126 | <YourApp />
127 | </ReflagProvider>;
128 | \`\`\`
129 |
130 | 1. Create a feature and generate type-safe definitions:
131 |
132 | \`\`\`bash
133 | npm i --save-dev @reflag/cli
134 | npx reflag new "Flag name"
135 | \`\`\`
136 |
137 | \`\`\`typescript
138 | // DO NOT EDIT THIS FILE. IT IS GENERATED BY THE BUCKET CLI AND WILL BE OVERWRITTEN.
139 | // eslint-disable
140 | // prettier-ignore
141 | import "@reflag/react-sdk";
142 |
143 | declare module "@reflag/react-sdk" {
144 | export interface Flags {
145 | "flag-key": {
146 | config: {
147 | payload: {
148 | tokens: number;
149 | };
150 | };
151 | };
152 | }
153 | }
154 | \`\`\`
155 |
156 | 1. Use features in your components:
157 |
158 | \`\`\`jsx
159 | import { useFlag } from "@reflag/react-sdk";
160 |
161 | function StartHuddleButton() {
162 | const {
163 | isLoading, // true while features are being loaded
164 | isEnabled, // boolean indicating if the feature is enabled
165 | config: {
166 | // feature configuration
167 | key, // string identifier for the config variant
168 | payload, // type-safe configuration object
169 | },
170 | track, // function to track feature usage
171 | requestFeedback, // function to request feedback for this feature
172 | } = useFlag("huddle");
173 |
174 | if (isLoading) {
175 | return <Loading />;
176 | }
177 |
178 | if (!isEnabled) {
179 | return null;
180 | }
181 |
182 | return (
183 | <>
184 | <button onClick={track}>Start huddle!</button>
185 | <button
186 | onClick={(e) =>
187 | requestFeedback({
188 | title: payload?.question ?? "How do you like the Huddles feature?",
189 | position: {
190 | type: "POPOVER",
191 | anchor: e.currentTarget as HTMLElement,
192 | },
193 | })
194 | }
195 | >
196 | Give feedback!
197 | </button>
198 | </>
199 | );
200 | }
201 | \`\`\`
202 |
203 | ### Core React Hooks
204 |
205 | - \`useFlag()\` - Access feature status, config, and tracking
206 | - \`useTrack()\` - Send custom events to Reflag
207 | - \`useRequestFeedback()\` - Open feedback dialog for a feature
208 | - \`useSendFeedback()\` - Programmatically send feedback
209 | - \`useUpdateUser()\` / \`useUpdateCompany()\` - Update user/company data
210 | - \`useUpdateOtherContext()\` - Update session-only context data
211 | - \`useClient()\` - Access the underlying Reflag client
212 |
213 | ## Node.js SDK Implementation
214 |
215 | ### Installation
216 |
217 | \`\`\`bash
218 | npm i @reflag/node-sdk
219 | \`\`\`
220 |
221 | ### Key Features
222 |
223 | - Server-side flag evaluation
224 | - User and company context management
225 | - Flexible integration options
226 | - Event tracking
227 |
228 | ### Basic Setup
229 |
230 | \`\`\`javascript
231 | import { ReflagClient } from "@reflag/node-sdk";
232 |
233 | const client = new ReflagClient({
234 | secretKey: process.env.REFLAG_SECRET_KEY,
235 | });
236 |
237 | // Check if a feature is enabled
238 | const isEnabled = await client.isEnabled("flag-key", {
239 | user: { id: "user_123", role: "admin" },
240 | company: { id: "company_456", plan: "enterprise" },
241 | });
242 | \`\`\`
243 |
244 | ### Context Management
245 |
246 | \`\`\`javascript
247 | // Set user and company context
248 | await client.setContext({
249 | user: {
250 | id: "user_123",
251 | email: "[email protected]",
252 | role: "admin",
253 | },
254 | company: {
255 | id: "company_456",
256 | name: "Acme Inc",
257 | plan: "enterprise",
258 | },
259 | });
260 |
261 | // Check feature after setting context
262 | const isEnabled = await client.isEnabled("flag-key");
263 | \`\`\`
264 |
265 | ### Flag Configuration
266 |
267 | \`\`\`javascript
268 | // Get feature configuration
269 | const config = await client.getConfig("flag-key", {
270 | user: { id: "user_123" },
271 | company: { id: "company_456" },
272 | });
273 |
274 | // Use the configuration
275 | console.log(config.payload.maxDuration);
276 | \`\`\`
277 |
278 | ### Event Tracking
279 |
280 | \`\`\`javascript
281 | // Track feature usage
282 | await client.track("flag-key", {
283 | user: { id: "user_123" },
284 | company: { id: "company_456" },
285 | metadata: { action: "completed" },
286 | });
287 |
288 | // Track custom events
289 | await client.trackEvent("custom-event", {
290 | user: { id: "user_123" },
291 | company: { id: "company_456" },
292 | metadata: { value: 42 },
293 | });
294 | \`\`\`
295 |
296 | ## Further Resources
297 |
298 | - [Official Documentation](mdc:https:/docs.reflag.com)
299 | - [Docs llms.txt](mdc:https:/docs.reflag.com/llms.txt)
300 | - [GitHub Repository](mdc:https:/github.com/reflagcom/javascript)
301 | - [Example React App](mdc:https:/github.com/reflagcom/javascript/tree/main/packages/react-sdk/dev)
302 | `.trim();
303 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/test/promptStorage.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import {
2 | afterAll,
3 | afterEach,
4 | beforeAll,
5 | describe,
6 | expect,
7 | test,
8 | vi,
9 | } from "vitest";
10 |
11 | import {
12 | checkPromptMessageCompleted,
13 | forgetAuthToken,
14 | getAuthToken,
15 | markPromptMessageCompleted,
16 | rememberAuthToken,
17 | } from "../src/feedback/promptStorage";
18 |
19 | describe("prompt-storage", () => {
20 | beforeAll(() => {
21 | const cookies: Record<string, string> = {};
22 |
23 | Object.defineProperty(document, "cookie", {
24 | set: (val: string) => {
25 | if (!val) {
26 | Object.keys(cookies).forEach((k) => delete cookies[k]);
27 | return;
28 | }
29 | const i = val.indexOf("=");
30 | cookies[val.slice(0, i)] = val.slice(i + 1);
31 | },
32 | get: () =>
33 | Object.entries(cookies)
34 | .map(([k, v]) => `${k}=${v}`)
35 | .join("; "),
36 | });
37 |
38 | vi.setSystemTime(new Date("2024-01-11T09:55:37.000Z"));
39 | });
40 |
41 | afterEach(() => {
42 | document.cookie = undefined!;
43 | vi.clearAllMocks();
44 | });
45 |
46 | afterAll(() => {
47 | vi.useRealTimers();
48 | });
49 |
50 | describe("markPromptMessageCompleted", () => {
51 | test("adds new cookie", async () => {
52 | markPromptMessageCompleted(
53 | "user",
54 | "prompt2",
55 | new Date("2024-01-04T14:01:20.000Z"),
56 | );
57 |
58 | expect(document.cookie).toBe(
59 | "reflag-prompt-user=prompt2; path=/; expires=Thu, 04 Jan 2024 14:01:20 GMT; sameSite=strict; secure",
60 | );
61 | });
62 |
63 | test("rewrites existing cookie", async () => {
64 | document.cookie =
65 | "reflag-prompt-user=prompt1; path=/; expires=Thu, 04 Jan 2021 14:01:20 GMT; sameSite=strict; secure";
66 |
67 | markPromptMessageCompleted(
68 | "user",
69 | "prompt2",
70 | new Date("2024-01-04T14:01:20.000Z"),
71 | );
72 |
73 | expect(document.cookie).toBe(
74 | "reflag-prompt-user=prompt2; path=/; expires=Thu, 04 Jan 2024 14:01:20 GMT; sameSite=strict; secure",
75 | );
76 | });
77 | });
78 |
79 | describe("checkPromptMessageCompleted", () => {
80 | test("cookie with same use and prompt results in true", async () => {
81 | document.cookie =
82 | "reflag-prompt-user=prompt; path=/; expires=Thu, 04 Jan 2024 14:01:20 GMT; sameSite=strict; secure";
83 |
84 | expect(checkPromptMessageCompleted("user", "prompt")).toBe(true);
85 |
86 | expect(document.cookie).toBe(
87 | "reflag-prompt-user=prompt; path=/; expires=Thu, 04 Jan 2024 14:01:20 GMT; sameSite=strict; secure",
88 | );
89 | });
90 |
91 | test("cookie with different prompt results in false", async () => {
92 | document.cookie =
93 | "reflag-prompt-user=prompt1; path=/; expires=Thu, 04 Jan 2024 14:01:20 GMT; sameSite=strict; secure";
94 |
95 | expect(checkPromptMessageCompleted("user", "prompt2")).toBe(false);
96 | });
97 |
98 | test("cookie with different user results in false", async () => {
99 | document.cookie =
100 | "reflag-prompt-user1=prompt1; path=/; expires=Thu, 04 Jan 2024 14:01:20 GMT; sameSite=strict; secure";
101 |
102 | expect(checkPromptMessageCompleted("user2", "prompt1")).toBe(false);
103 | });
104 |
105 | test("no cookie results in false", async () => {
106 | expect(checkPromptMessageCompleted("user", "prompt2")).toBe(false);
107 | });
108 | });
109 |
110 | describe("rememberAuthToken", () => {
111 | test("adds new cookie if none was there", async () => {
112 | expect(document.cookie).toBe("");
113 |
114 | rememberAuthToken(
115 | 'user1"%%',
116 | "channel:suffix",
117 | "secret$%",
118 | new Date("2024-01-02T15:02:20.000Z"),
119 | );
120 |
121 | expect(document.cookie).toBe(
122 | "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",
123 | );
124 | });
125 |
126 | test("replaces existing cookie for same user", async () => {
127 | document.cookie =
128 | "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";
129 |
130 | rememberAuthToken(
131 | 'user1"%%',
132 | "channel2:suffix2",
133 | "secret2$%",
134 | new Date("2023-01-02T15:02:20.000Z"),
135 | );
136 |
137 | expect(document.cookie).toBe(
138 | "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",
139 | );
140 | });
141 | });
142 |
143 | describe("forgetAuthToken", () => {
144 | test("clears the user's cookie if even if there was nothing before", async () => {
145 | forgetAuthToken("user");
146 |
147 | expect(document.cookie).toBe(
148 | "reflag-token-user=; path=/; expires=Wed, 10 Jan 2024 09:55:37 GMT",
149 | );
150 | });
151 |
152 | test("clears the user's cookie", async () => {
153 | document.cookie =
154 | "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";
155 |
156 | forgetAuthToken("user1");
157 |
158 | expect(document.cookie).toBe(
159 | "reflag-token-user1=; path=/; expires=Wed, 10 Jan 2024 09:55:37 GMT",
160 | );
161 | });
162 |
163 | test("does nothing if there is a cookie for a different user", async () => {
164 | document.cookie =
165 | "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";
166 |
167 | forgetAuthToken("user2");
168 |
169 | expect(document.cookie).toBe(
170 | "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",
171 | );
172 | });
173 | });
174 |
175 | describe("getAuthToken", () => {
176 | test("returns the auth token if it's available for the user", async () => {
177 | document.cookie =
178 | "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";
179 |
180 | expect(getAuthToken('user1"%%')).toStrictEqual({
181 | channel: "channel:suffix",
182 | token: "secret$%",
183 | });
184 | });
185 |
186 | test("return undefined if no cookie for user", async () => {
187 | document.cookie =
188 | "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";
189 |
190 | expect(getAuthToken("user2")).toBeUndefined();
191 | });
192 |
193 | test("returns undefined if no cookie", async () => {
194 | expect(getAuthToken("user")).toBeUndefined();
195 | });
196 |
197 | test("return undefined if corrupted cookie", async () => {
198 | document.cookie =
199 | "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";
200 |
201 | expect(getAuthToken("user")).toBeUndefined();
202 | });
203 |
204 | test("return undefined if a field is missing", async () => {
205 | document.cookie =
206 | "reflag-token-user={%2C%22token%22:%22secret$%25%22}; path=/; expires=Tue, 02 Jan 2024 15:02:20 GMT; sameSite=strict; secure";
207 |
208 | expect(getAuthToken("user")).toBeUndefined();
209 | });
210 | });
211 |
212 | test("manages all cookies for the user", () => {
213 | rememberAuthToken(
214 | "user1",
215 | "channel:suffix",
216 | "secret$%",
217 | new Date("2024-01-02T15:02:20.000Z"),
218 | );
219 |
220 | markPromptMessageCompleted(
221 | "user1",
222 | "alex-prompt",
223 | new Date("2024-01-02T15:03:20.000Z"),
224 | );
225 |
226 | expect(document.cookie).toBe(
227 | "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",
228 | );
229 |
230 | forgetAuthToken("user1");
231 |
232 | expect(document.cookie).toBe(
233 | "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",
234 | );
235 | });
236 | });
237 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/src/feedback/ui/FeedbackForm.tsx:
--------------------------------------------------------------------------------
```typescript
1 | import { FunctionComponent, h } from "preact";
2 | import { useCallback, useEffect, useRef, useState } from "preact/hooks";
3 |
4 | import { Check } from "../../ui/icons/Check";
5 | import { CheckCircle } from "../../ui/icons/CheckCircle";
6 |
7 | import { Button } from "./Button";
8 | import { Plug } from "./Plug";
9 | import { StarRating } from "./StarRating";
10 | import {
11 | FeedbackScoreSubmission,
12 | FeedbackSubmission,
13 | FeedbackTranslations,
14 | } from "./types";
15 |
16 | const ANIMATION_SPEED = 400;
17 |
18 | function getFeedbackDataFromForm(el: HTMLFormElement) {
19 | const formData = new FormData(el);
20 | return {
21 | score: Number(formData.get("score")?.toString()),
22 | comment: (formData.get("comment")?.toString() || "").trim(),
23 | };
24 | }
25 |
26 | type FeedbackFormProps = {
27 | t: FeedbackTranslations;
28 | question: string;
29 | scoreState: "idle" | "submitting" | "submitted";
30 | openWithCommentVisible: boolean;
31 | onInteraction: () => void;
32 | onSubmit: (
33 | data: Omit<FeedbackSubmission, "feebackId">,
34 | ) => Promise<void> | void;
35 | onScoreSubmit: (
36 | score: Omit<FeedbackScoreSubmission, "feebackId">,
37 | ) => Promise<void> | void;
38 | };
39 |
40 | export const FeedbackForm: FunctionComponent<FeedbackFormProps> = ({
41 | question,
42 | scoreState,
43 | openWithCommentVisible,
44 | onInteraction,
45 | onSubmit,
46 | onScoreSubmit,
47 | t,
48 | }) => {
49 | const [hasRating, setHasRating] = useState(false);
50 | const [status, setStatus] = useState<"idle" | "submitting" | "submitted">(
51 | "idle",
52 | );
53 | const [error, setError] = useState<string>();
54 | const [showForm, setShowForm] = useState(true);
55 |
56 | const handleSubmit: h.JSX.GenericEventHandler<HTMLFormElement> = async (
57 | e,
58 | ) => {
59 | e.preventDefault();
60 | const data: FeedbackSubmission = {
61 | ...getFeedbackDataFromForm(e.target as HTMLFormElement),
62 | question,
63 | };
64 | if (!data.score) return;
65 | setError("");
66 | try {
67 | setStatus("submitting");
68 | await onSubmit(data);
69 | setStatus("submitted");
70 | } catch (err) {
71 | setStatus("idle");
72 | if (err instanceof Error) {
73 | setError(err.message);
74 | } else if (typeof err === "string") {
75 | setError(err);
76 | } else {
77 | setError("Couldn't submit feedback. Please try again.");
78 | }
79 | }
80 | };
81 |
82 | const containerRef = useRef<HTMLDivElement>(null);
83 | const formRef = useRef<HTMLFormElement>(null);
84 | const headerRef = useRef<HTMLDivElement>(null);
85 | const expandedContentRef = useRef<HTMLDivElement>(null);
86 | const submittedRef = useRef<HTMLDivElement>(null);
87 |
88 | const transitionToDefault = useCallback(() => {
89 | if (containerRef.current === null) return;
90 | if (headerRef.current === null) return;
91 | if (expandedContentRef.current === null) return;
92 |
93 | containerRef.current.style.maxHeight = `${headerRef.current.clientHeight}px`;
94 |
95 | expandedContentRef.current.style.position = "absolute";
96 | expandedContentRef.current.style.opacity = "0";
97 | expandedContentRef.current.style.pointerEvents = "none";
98 | }, [containerRef, headerRef, expandedContentRef]);
99 |
100 | const transitionToExpanded = useCallback(() => {
101 | if (containerRef.current === null) return;
102 | if (headerRef.current === null) return;
103 | if (expandedContentRef.current === null) return;
104 |
105 | containerRef.current.style.maxHeight = `${
106 | headerRef.current.clientHeight + // Header height
107 | expandedContentRef.current.clientHeight + // Comment + Button Height
108 | 10 // Gap height
109 | }px`;
110 |
111 | expandedContentRef.current.style.position = "relative";
112 | expandedContentRef.current.style.opacity = "1";
113 | expandedContentRef.current.style.pointerEvents = "all";
114 | }, [containerRef, headerRef, expandedContentRef]);
115 |
116 | const transitionToSuccess = useCallback(() => {
117 | if (containerRef.current === null) return;
118 | if (formRef.current === null) return;
119 | if (submittedRef.current === null) return;
120 |
121 | formRef.current.style.opacity = "0";
122 | formRef.current.style.pointerEvents = "none";
123 | containerRef.current.style.maxHeight = `${submittedRef.current.clientHeight}px`;
124 |
125 | // Fade in "submitted" step once container has resized
126 | setTimeout(() => {
127 | submittedRef.current!.style.position = "relative";
128 | submittedRef.current!.style.opacity = "1";
129 | submittedRef.current!.style.pointerEvents = "all";
130 | setShowForm(false);
131 | }, ANIMATION_SPEED + 10);
132 | }, [formRef, containerRef, submittedRef]);
133 |
134 | useEffect(() => {
135 | if (status === "submitted") {
136 | transitionToSuccess();
137 | } else if (openWithCommentVisible || hasRating) {
138 | transitionToExpanded();
139 | } else {
140 | transitionToDefault();
141 | }
142 | }, [
143 | transitionToDefault,
144 | transitionToExpanded,
145 | transitionToSuccess,
146 | openWithCommentVisible,
147 | hasRating,
148 | status,
149 | ]);
150 |
151 | return (
152 | <div ref={containerRef} class="container">
153 | <div ref={submittedRef} class="submitted">
154 | <div class="submitted-check">
155 | <CheckCircle height={24} width={24} />
156 | </div>
157 | <p class="text">{t.SuccessMessage}</p>
158 | <Plug />
159 | </div>
160 | {showForm && (
161 | <form
162 | ref={formRef}
163 | class="form"
164 | method="dialog"
165 | style={{ opacity: 1 }}
166 | onClick={onInteraction}
167 | onFocus={onInteraction}
168 | onFocusCapture={onInteraction}
169 | onSubmit={handleSubmit}
170 | >
171 | <div
172 | ref={headerRef}
173 | aria-labelledby="reflag-feedback-score-label"
174 | class="form-control"
175 | role="group"
176 | >
177 | <div class="title" id="reflag-feedback-score-label">
178 | {question}
179 | </div>
180 | <StarRating
181 | name="score"
182 | t={t}
183 | onChange={async (e) => {
184 | setHasRating(true);
185 | await onScoreSubmit({
186 | question,
187 | score: Number(e.currentTarget.value),
188 | });
189 | }}
190 | />
191 |
192 | <ScoreStatus scoreState={scoreState} t={t} />
193 | </div>
194 |
195 | <div ref={expandedContentRef} class="form-expanded-content">
196 | <div class="form-control">
197 | <textarea
198 | class="textarea"
199 | id="reflag-feedback-comment-label"
200 | name="comment"
201 | placeholder={t.QuestionPlaceholder}
202 | rows={4}
203 | />
204 | </div>
205 |
206 | {error && <p class="error">{error}</p>}
207 |
208 | <Button
209 | disabled={
210 | !hasRating ||
211 | status === "submitting" ||
212 | scoreState === "submitting"
213 | }
214 | type="submit"
215 | >
216 | {t.SendButton}
217 | </Button>
218 |
219 | <Plug />
220 | </div>
221 | </form>
222 | )}
223 | </div>
224 | );
225 | };
226 |
227 | const ScoreStatus: FunctionComponent<{
228 | t: FeedbackTranslations;
229 | scoreState: "idle" | "submitting" | "submitted";
230 | }> = ({ t, scoreState }) => {
231 | // Keep track of whether we can show a loading indication - only if 400ms have
232 | // elapsed without the score request finishing.
233 | const [loadingTimeElapsed, setLoadingTimeElapsed] = useState(false);
234 |
235 | // Keep track of whether we can fall back to the idle/loading states - once
236 | // it's been submit once it won't, to prevent flashing.
237 | const [hasBeenSubmitted, setHasBeenSubmitted] = useState(false);
238 |
239 | useEffect(() => {
240 | if (scoreState === "idle") {
241 | setLoadingTimeElapsed(false);
242 | return;
243 | }
244 |
245 | if (scoreState === "submitted") {
246 | setLoadingTimeElapsed(false);
247 | setHasBeenSubmitted(true);
248 | return;
249 | }
250 |
251 | const timer = setTimeout(() => {
252 | setLoadingTimeElapsed(true);
253 | }, 400);
254 |
255 | return () => clearTimeout(timer);
256 | }, [scoreState]);
257 |
258 | const showIdle =
259 | scoreState === "idle" ||
260 | (scoreState === "submitting" && !hasBeenSubmitted && !loadingTimeElapsed);
261 | const showLoading =
262 | scoreState !== "submitted" && !hasBeenSubmitted && loadingTimeElapsed;
263 | const showSubmitted = scoreState === "submitted" || hasBeenSubmitted;
264 |
265 | return (
266 | <div class="score-status-container">
267 | <span class="score-status" style={{ opacity: showIdle ? 1 : 0 }}>
268 | {t.ScoreStatusDescription}
269 | </span>
270 |
271 | <div class="score-status" style={{ opacity: showLoading ? 1 : 0 }}>
272 | {t.ScoreStatusLoading}
273 | </div>
274 |
275 | <span class="score-status" style={{ opacity: showSubmitted ? 1 : 0 }}>
276 | <Check height={14} style={{ marginRight: 3 }} width={14} />{" "}
277 | {t.ScoreStatusReceived}
278 | </span>
279 | </div>
280 | );
281 | };
282 |
```
--------------------------------------------------------------------------------
/packages/node-sdk/test/utils.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { createHash } from "crypto";
2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3 |
4 | import {
5 | decorateLogger,
6 | hashObject,
7 | isObject,
8 | mergeSkipUndefined,
9 | ok,
10 | once,
11 | TimeoutError,
12 | withTimeout,
13 | } from "../src/utils";
14 |
15 | describe("isObject", () => {
16 | it("should return true for an object", () => {
17 | expect(isObject({})).toBe(true);
18 | });
19 |
20 | it("should return false for an array", () => {
21 | expect(isObject([])).toBe(false);
22 | });
23 |
24 | it("should return false for a string", () => {
25 | expect(isObject("")).toBe(false);
26 | });
27 |
28 | it("should return false for a number", () => {
29 | expect(isObject(0)).toBe(false);
30 | });
31 |
32 | it("should return false for a boolean", () => {
33 | expect(isObject(true)).toBe(false);
34 | });
35 |
36 | it("should return false for null", () => {
37 | expect(isObject(null)).toBe(false);
38 | });
39 |
40 | it("should return false for undefined", () => {
41 | expect(isObject(undefined)).toBe(false);
42 | });
43 | });
44 |
45 | describe("ok", () => {
46 | it("should throw an error if the condition is false", () => {
47 | expect(() => ok(false, "error")).toThrowError("error");
48 | });
49 |
50 | it("should not throw an error if the condition is true", () => {
51 | expect(() => ok(true, "error")).not.toThrow();
52 | });
53 | });
54 |
55 | describe("decorateLogger", () => {
56 | it("should decorate the logger", () => {
57 | const logger = {
58 | debug: vi.fn(),
59 | info: vi.fn(),
60 | warn: vi.fn(),
61 | error: vi.fn(),
62 | };
63 | const decorated = decorateLogger("prefix", logger);
64 |
65 | decorated.debug("message");
66 | decorated.info("message");
67 | decorated.warn("message");
68 | decorated.error("message");
69 |
70 | expect(logger.debug).toHaveBeenCalledWith("prefix message");
71 | expect(logger.info).toHaveBeenCalledWith("prefix message");
72 | expect(logger.warn).toHaveBeenCalledWith("prefix message");
73 | expect(logger.error).toHaveBeenCalledWith("prefix message");
74 | });
75 |
76 | it("should throw an error if the prefix is not a string", () => {
77 | expect(() => decorateLogger(0 as any, {} as any)).toThrowError(
78 | "prefix must be a string",
79 | );
80 | });
81 |
82 | it("should throw an error if the logger is not an object", () => {
83 | expect(() => decorateLogger("", 0 as any)).toThrowError(
84 | "logger must be an object",
85 | );
86 | });
87 | });
88 |
89 | describe("mergeSkipUndefined", () => {
90 | it("merges two objects with no undefined values", () => {
91 | const target = { a: 1, b: 2 };
92 | const source = { b: 3, c: 4 };
93 | const result = mergeSkipUndefined(target, source);
94 | expect(result).toEqual({ a: 1, b: 3, c: 4 });
95 | });
96 |
97 | it("merges two objects where the source has undefined values", () => {
98 | const target = { a: 1, b: 2 };
99 | const source = { b: undefined, c: 4 };
100 | const result = mergeSkipUndefined(target, source);
101 | expect(result).toEqual({ a: 1, b: 2, c: 4 });
102 | });
103 |
104 | it("merges two objects where the target has undefined values", () => {
105 | const target = { a: 1, b: undefined };
106 | const source = { b: 3, c: 4 };
107 | const result = mergeSkipUndefined(target, source);
108 | expect(result).toEqual({ a: 1, b: 3, c: 4 });
109 | });
110 |
111 | it("merges two objects where both have undefined values", () => {
112 | const target = { a: 1, b: undefined };
113 | const source = { b: undefined, c: 4 };
114 | const result = mergeSkipUndefined(target, source);
115 | expect(result).toEqual({ a: 1, c: 4 });
116 | });
117 |
118 | it("merges two empty objects", () => {
119 | const target = {};
120 | const source = {};
121 | const result = mergeSkipUndefined(target, source);
122 | expect(result).toEqual({});
123 | });
124 | });
125 |
126 | describe("hashObject", () => {
127 | it("should throw if the given value is not an object", () => {
128 | expect(() => hashObject(null as any)).toThrowError(
129 | "validation failed: obj must be an object",
130 | );
131 |
132 | expect(() => hashObject("string" as any)).toThrowError(
133 | "validation failed: obj must be an object",
134 | );
135 |
136 | expect(() => hashObject([1, 2, 3] as any)).toThrowError(
137 | "validation failed: obj must be an object",
138 | );
139 | });
140 |
141 | it("should return consistent hash for same object content", () => {
142 | const obj = { name: "Alice", age: 30 };
143 | const hash1 = hashObject(obj);
144 | const hash2 = hashObject({ age: 30, name: "Alice" }); // different key order
145 | expect(hash1).toBe(hash2);
146 | });
147 |
148 | it("should return different hash for different objects", () => {
149 | const obj1 = { name: "Alice", age: 30 };
150 | const obj2 = { name: "Bob", age: 25 };
151 | const hash1 = hashObject(obj1);
152 | const hash2 = hashObject(obj2);
153 | expect(hash1).not.toBe(hash2);
154 | });
155 |
156 | it("should correctly hash nested objects", () => {
157 | const obj = { user: { name: "Alice", details: { age: 30, active: true } } };
158 | const hash = hashObject(obj);
159 |
160 | const expectedHash = createHash("sha1");
161 | expectedHash.update("user");
162 | expectedHash.update("details");
163 | expectedHash.update("active");
164 | expectedHash.update("true");
165 | expectedHash.update("age");
166 | expectedHash.update("30");
167 | expectedHash.update("name");
168 | expectedHash.update("Alice");
169 |
170 | expect(hash).toBe(expectedHash.digest("base64"));
171 | });
172 |
173 | it("should hash arrays within objects", () => {
174 | const obj = { numbers: [1, 2, 3] };
175 | const hash = hashObject(obj);
176 |
177 | const expectedHash = createHash("sha1");
178 | expectedHash.update("numbers");
179 | expectedHash.update("1");
180 | expectedHash.update("2");
181 | expectedHash.update("3");
182 |
183 | expect(hash).toBe(expectedHash.digest("base64"));
184 | });
185 | });
186 |
187 | describe("once()", () => {
188 | it("should call the function only once with void return value", () => {
189 | const fn = vi.fn();
190 | const onceFn = once(fn);
191 |
192 | onceFn();
193 | onceFn();
194 | onceFn();
195 |
196 | expect(fn).toHaveBeenCalledTimes(1);
197 | });
198 |
199 | it("should call the function only once", () => {
200 | const fn = vi.fn().mockReturnValue(1);
201 | const onceFn = once(fn);
202 |
203 | expect(onceFn()).toBe(1);
204 | expect(onceFn()).toBe(1);
205 | expect(onceFn()).toBe(1);
206 |
207 | expect(fn).toHaveBeenCalledTimes(1);
208 | });
209 | });
210 |
211 | describe("withTimeout()", () => {
212 | beforeEach(() => {
213 | vi.useFakeTimers();
214 | });
215 |
216 | afterEach(() => {
217 | vi.useRealTimers();
218 | });
219 |
220 | it("should resolve when promise completes before timeout", async () => {
221 | const promise = Promise.resolve("success");
222 | const result = withTimeout(promise, 1000);
223 |
224 | await expect(result).resolves.toBe("success");
225 | });
226 |
227 | it("should reject with TimeoutError when promise takes too long", async () => {
228 | const slowPromise = new Promise((resolve) => {
229 | setTimeout(() => resolve("too late"), 2000);
230 | });
231 |
232 | const result = withTimeout(slowPromise, 1000);
233 |
234 | vi.advanceTimersByTime(1000);
235 |
236 | await expect(result).rejects.toThrow("Operation timed out after 1000ms");
237 | await expect(result).rejects.toBeInstanceOf(TimeoutError);
238 | });
239 |
240 | it("should propagate original promise rejection", async () => {
241 | const error = new Error("original error");
242 | const failedPromise = Promise.reject(error);
243 |
244 | const result = withTimeout(failedPromise, 1000);
245 |
246 | await expect(result).rejects.toBe(error);
247 | });
248 |
249 | it("should reject immediately for negative timeout", async () => {
250 | const promise = Promise.resolve("success");
251 |
252 | await expect(async () => {
253 | await withTimeout(promise, -1);
254 | }).rejects.toThrow("validation failed: timeout must be a positive number");
255 | });
256 |
257 | it("should reject immediately for zero timeout", async () => {
258 | const promise = Promise.resolve("success");
259 |
260 | await expect(async () => {
261 | await withTimeout(promise, 0);
262 | }).rejects.toThrow("validation failed: timeout must be a positive number");
263 | });
264 |
265 | it("should clean up timeout when promise resolves", async () => {
266 | const clearTimeoutSpy = vi.spyOn(global, "clearTimeout");
267 | const promise = Promise.resolve("success");
268 |
269 | await withTimeout(promise, 1000);
270 | await vi.runAllTimersAsync();
271 |
272 | expect(clearTimeoutSpy).toHaveBeenCalled();
273 | clearTimeoutSpy.mockRestore();
274 | });
275 |
276 | it("should clean up timeout when promise rejects", async () => {
277 | const clearTimeoutSpy = vi.spyOn(global, "clearTimeout");
278 | const promise = Promise.reject(new Error("fail"));
279 |
280 | await expect(withTimeout(promise, 1000)).rejects.toThrow("fail");
281 |
282 | expect(clearTimeoutSpy).toHaveBeenCalled();
283 | clearTimeoutSpy.mockRestore();
284 | });
285 |
286 | it("should not resolve after timeout occurs", async () => {
287 | const slowPromise = new Promise((resolve) => {
288 | setTimeout(() => resolve("too late"), 2000);
289 | });
290 |
291 | const result = withTimeout(slowPromise, 1000);
292 |
293 | vi.advanceTimersByTime(1000); // Trigger timeout
294 | await expect(result).rejects.toThrow("Operation timed out after 1000ms");
295 |
296 | vi.advanceTimersByTime(1000); // Complete the original promise
297 | // The promise should still be rejected with the timeout error
298 | await expect(result).rejects.toThrow("Operation timed out after 1000ms");
299 | });
300 | });
301 |
```
--------------------------------------------------------------------------------
/packages/cli/utils/auth.ts:
--------------------------------------------------------------------------------
```typescript
1 | import crypto from "crypto";
2 | import http from "http";
3 | import chalk from "chalk";
4 | import open from "open";
5 |
6 | import { authStore } from "../stores/auth.js";
7 | import { configStore } from "../stores/config.js";
8 |
9 | import {
10 | CLIENT_VERSION_HEADER_NAME,
11 | CLIENT_VERSION_HEADER_VALUE,
12 | DEFAULT_AUTH_TIMEOUT,
13 | } from "./constants.js";
14 | import { ResponseError } from "./errors.js";
15 | import { ParamType } from "./types.js";
16 | import { errorUrl, successUrl } from "./urls.js";
17 |
18 | const maxRetryCount = 1;
19 |
20 | interface waitForAccessToken {
21 | accessToken: string;
22 | expiresAt: Date;
23 | }
24 |
25 | async function getOAuthServerUrls(apiUrl: string) {
26 | const { protocol, host } = new URL(apiUrl);
27 | const wellKnownUrl = `${protocol}//${host}/.well-known/oauth-authorization-server`;
28 |
29 | const response = await fetch(wellKnownUrl, {
30 | signal: AbortSignal.timeout(5000),
31 | });
32 |
33 | if (response.ok) {
34 | const data = (await response.json()) as {
35 | authorization_endpoint: string;
36 | token_endpoint: string;
37 | registration_endpoint: string;
38 | issuer: string;
39 | };
40 |
41 | return {
42 | registrationEndpoint:
43 | data.registration_endpoint ?? `${data.issuer}/oauth/register`,
44 | authorizationEndpoint: data.authorization_endpoint,
45 | tokenEndpoint: data.token_endpoint,
46 | issuer: data.issuer,
47 | };
48 | }
49 |
50 | throw new Error("Failed to fetch OAuth server metadata");
51 | }
52 |
53 | async function registerClient(
54 | registrationEndpoint: string,
55 | redirectUri: string,
56 | ) {
57 | const registrationResponse = await fetch(registrationEndpoint, {
58 | method: "POST",
59 | headers: {
60 | "Content-Type": "application/json",
61 | },
62 | body: JSON.stringify({
63 | client_name: "Reflag CLI",
64 | token_endpoint_auth_method: "none",
65 | grant_types: ["authorization_code"],
66 | redirect_uris: [redirectUri],
67 | }),
68 | signal: AbortSignal.timeout(5000),
69 | });
70 |
71 | if (!registrationResponse.ok) {
72 | throw new Error(`Could not register client with OAuth server`);
73 | }
74 |
75 | const registrationData = (await registrationResponse.json()) as {
76 | client_id: string;
77 | };
78 |
79 | return registrationData.client_id;
80 | }
81 |
82 | async function exchangeCodeForToken(
83 | tokenEndpoint: string,
84 | clientId: string,
85 | code: string,
86 | codeVerifier: string,
87 | redirectUri: string,
88 | ) {
89 | const response = await fetch(tokenEndpoint, {
90 | method: "POST",
91 | headers: {
92 | "Content-Type": "application/x-www-form-urlencoded",
93 | },
94 | body: new URLSearchParams({
95 | grant_type: "authorization_code",
96 | client_id: clientId,
97 | code,
98 | code_verifier: codeVerifier,
99 | redirect_uri: redirectUri,
100 | }),
101 | signal: AbortSignal.timeout(5000),
102 | });
103 |
104 | if (!response.ok) {
105 | let errorDescription: string | undefined;
106 |
107 | try {
108 | const errorResponse = await response.json();
109 | errorDescription = errorResponse.error_description || errorResponse.error;
110 | } catch {
111 | // ignore
112 | }
113 |
114 | return { error: errorDescription ?? "unknown error" };
115 | }
116 |
117 | const successResponse = (await response.json()) as {
118 | access_token: string;
119 | expires_in: number;
120 | };
121 |
122 | return {
123 | accessToken: successResponse.access_token,
124 | expiresAt: new Date(Date.now() + successResponse.expires_in * 1000),
125 | };
126 | }
127 |
128 | function createChallenge() {
129 | // PKCE code verifier and challenge
130 | const codeVerifier = crypto.randomBytes(32).toString("base64url");
131 | const codeChallenge = crypto
132 | .createHash("sha256")
133 | .update(codeVerifier)
134 | .digest("base64")
135 | .replace(/=/g, "")
136 | .replace(/\+/g, "-")
137 | .replace(/\//g, "_");
138 |
139 | const state = crypto.randomUUID();
140 |
141 | return { codeVerifier, codeChallenge, state };
142 | }
143 |
144 | export async function waitForAccessToken(baseUrl: string, apiUrl: string) {
145 | const { authorizationEndpoint, tokenEndpoint, registrationEndpoint } =
146 | await getOAuthServerUrls(apiUrl);
147 |
148 | let resolve: (args: waitForAccessToken) => void;
149 | let reject: (arg0: Error) => void;
150 |
151 | const promise = new Promise<waitForAccessToken>((res, rej) => {
152 | resolve = res;
153 | reject = rej;
154 | });
155 |
156 | const { codeVerifier, codeChallenge, state } = createChallenge();
157 |
158 | const timeout = setTimeout(() => {
159 | cleanupAndReject(
160 | `authentication timed out after ${DEFAULT_AUTH_TIMEOUT / 1000} seconds`,
161 | );
162 | }, DEFAULT_AUTH_TIMEOUT);
163 |
164 | function cleanupAndReject(message: string) {
165 | cleanup();
166 | reject(new Error(`Could not authenticate: ${message}`));
167 | }
168 |
169 | function cleanup() {
170 | clearTimeout(timeout);
171 | server.close();
172 | server.closeAllConnections();
173 | }
174 |
175 | const server = http.createServer();
176 |
177 | server.listen();
178 |
179 | const address = server.address();
180 | if (address == null || typeof address !== "object") {
181 | throw new Error("Could not start server");
182 | }
183 |
184 | const callbackPath = "/oauth_callback";
185 | const redirectUri = `http://localhost:${address.port}${callbackPath}`;
186 |
187 | const clientId = await registerClient(registrationEndpoint, redirectUri);
188 |
189 | const params = {
190 | response_type: "code",
191 | client_id: clientId,
192 | redirect_uri: redirectUri,
193 | state,
194 | code_challenge: codeChallenge,
195 | code_challenge_method: "S256",
196 | };
197 |
198 | const browserUrl = `${authorizationEndpoint}?${new URLSearchParams(params).toString()}`;
199 |
200 | server.on("request", async (req, res) => {
201 | if (!clientId || !redirectUri) {
202 | res.writeHead(500).end("Something went wrong");
203 |
204 | cleanupAndReject("something went wrong");
205 | return;
206 | }
207 |
208 | const url = new URL(req.url ?? "/", "http://127.0.0.1");
209 |
210 | if (url.pathname !== callbackPath) {
211 | res.writeHead(404).end("Invalid path");
212 |
213 | cleanupAndReject("invalid path");
214 | return;
215 | }
216 |
217 | const error = url.searchParams.get("error");
218 | if (error) {
219 | res.writeHead(400).end("Could not authenticate");
220 |
221 | const errorDescription = url.searchParams.get("error_description");
222 | cleanupAndReject(`${errorDescription || error} `);
223 | return;
224 | }
225 |
226 | const code = url.searchParams.get("code");
227 | if (!code) {
228 | res.writeHead(400).end("Could not authenticate");
229 |
230 | cleanupAndReject("no code provided");
231 | return;
232 | }
233 |
234 | const response = await exchangeCodeForToken(
235 | tokenEndpoint,
236 | clientId,
237 | code,
238 | codeVerifier,
239 | redirectUri,
240 | );
241 |
242 | if ("error" in response) {
243 | res
244 | .writeHead(302, {
245 | location: errorUrl(
246 | baseUrl,
247 | "Could not authenticate: unable to fetch access token",
248 | ),
249 | })
250 | .end("Could not authenticate");
251 |
252 | cleanupAndReject(JSON.stringify(response.error));
253 | return;
254 | }
255 |
256 | res
257 | .writeHead(302, {
258 | location: successUrl(baseUrl),
259 | })
260 | .end("Authentication successful");
261 |
262 | cleanup();
263 | resolve(response);
264 | });
265 |
266 | console.log(
267 | `Opened web browser to facilitate login: ${chalk.cyan(browserUrl)}`,
268 | );
269 |
270 | void open(browserUrl);
271 |
272 | return promise;
273 | }
274 |
275 | export async function authRequest<T = Record<string, unknown>>(
276 | url: string,
277 | options?: RequestInit & {
278 | params?: Record<string, ParamType | ParamType[] | null | undefined>;
279 | },
280 | retryCount = 0,
281 | ): Promise<T> {
282 | const { baseUrl, apiUrl } = configStore.getConfig();
283 | const { token, isApiKey } = authStore.getToken(baseUrl);
284 |
285 | if (!token) {
286 | const accessToken = await waitForAccessToken(baseUrl, apiUrl);
287 |
288 | await authStore.setToken(baseUrl, accessToken.accessToken);
289 | return authRequest(url, options);
290 | }
291 |
292 | if (url.startsWith("/")) {
293 | url = url.slice(1);
294 | }
295 |
296 | const resolvedUrl = new URL(`${apiUrl}/${url}`);
297 |
298 | if (options?.params) {
299 | Object.entries(options.params).forEach(([key, value]) => {
300 | if (value !== null && value !== undefined) {
301 | if (Array.isArray(value)) {
302 | value.forEach((v) => resolvedUrl.searchParams.append(key, String(v)));
303 | } else {
304 | resolvedUrl.searchParams.set(key, String(value));
305 | }
306 | }
307 | });
308 | }
309 |
310 | let response: Response | undefined;
311 |
312 | try {
313 | response = await fetch(resolvedUrl, {
314 | ...options,
315 | headers: {
316 | ...options?.headers,
317 | Authorization: `Bearer ${token}`,
318 | [CLIENT_VERSION_HEADER_NAME]: CLIENT_VERSION_HEADER_VALUE(
319 | configStore.getClientVersion() ?? "unknown",
320 | ),
321 | },
322 | });
323 | } catch (error: unknown) {
324 | const message =
325 | error && typeof error == "object" && "message" in error
326 | ? error.message
327 | : "unknown";
328 |
329 | throw new Error(`Failed to connect to "${resolvedUrl}". Error: ${message}`);
330 | }
331 |
332 | if (!response.ok) {
333 | if (response.status === 401) {
334 | if (isApiKey) {
335 | throw new Error(
336 | `The provided API key is not valid for "${resolvedUrl}".`,
337 | );
338 | }
339 |
340 | await authStore.setToken(baseUrl, null);
341 |
342 | if (retryCount < maxRetryCount) {
343 | return authRequest(url, options, retryCount + 1);
344 | }
345 | }
346 |
347 | const data = await response.json();
348 | throw new ResponseError(data);
349 | }
350 |
351 | return response.json();
352 | }
353 |
```
--------------------------------------------------------------------------------
/packages/vue-sdk/src/hooks.ts:
--------------------------------------------------------------------------------
```typescript
1 | import {
2 | computed,
3 | inject,
4 | InjectionKey,
5 | onMounted,
6 | onUnmounted,
7 | ref,
8 | } from "vue";
9 |
10 | import {
11 | HookArgs,
12 | InitOptions,
13 | ReflagClient,
14 | RequestFeedbackData,
15 | UnassignedFeedback,
16 | } from "@reflag/browser-sdk";
17 |
18 | import {
19 | FlagKey,
20 | ProviderContextType,
21 | RequestFlagFeedbackOptions,
22 | TypedFlags,
23 | } from "./types";
24 | import { SDK_VERSION } from "./version";
25 |
26 | export const ProviderSymbol: InjectionKey<ProviderContextType> =
27 | Symbol("ReflagProvider");
28 |
29 | /**
30 | * Map of clients by context key. Used to deduplicate initialization of the client.
31 | * @internal
32 | */
33 | const reflagClients = new Map<string, ReflagClient>();
34 |
35 | /**
36 | * Returns the ReflagClient for a given publishable key.
37 | * Only creates a new ReflagClient if not already created or if it hook is run on the server.
38 | * @internal
39 | */
40 | export function useReflagClient(
41 | initOptions: InitOptions,
42 | debug = false,
43 | ): ReflagClient {
44 | const isServer = typeof window === "undefined";
45 | if (isServer || !reflagClients.has(initOptions.publishableKey)) {
46 | const client = new ReflagClient({
47 | ...initOptions,
48 | sdkVersion: SDK_VERSION,
49 | logger: debug ? console : undefined,
50 | });
51 | if (!isServer) {
52 | reflagClients.set(initOptions.publishableKey, client);
53 | }
54 | return client;
55 | }
56 | return reflagClients.get(initOptions.publishableKey)!;
57 | }
58 |
59 | /**
60 | * Vue composable for getting the state of a given flag for the current context.
61 | *
62 | * This composable returns an object with the state of the flag for the current context.
63 | *
64 | * @param key - The key of the flag to get the state of.
65 | * @returns An object with the state of the flag.
66 | *
67 | * @example
68 | * ```ts
69 | * import { useFlag } from '@reflag/vue-sdk';
70 | *
71 | * const { isEnabled, config, track, requestFeedback } = useFlag("huddles");
72 | *
73 | * function StartHuddlesButton() {
74 | * const { isEnabled, config: { payload }, track } = useFlag("huddles");
75 | * if (isEnabled) {
76 | * return <button onClick={() => track()}>{payload?.buttonTitle ?? "Start Huddles"}</button>;
77 | * }
78 | * ```
79 | */
80 | export function useFlag<TKey extends FlagKey>(key: TKey): TypedFlags[TKey] {
81 | const client = useClient();
82 | const isLoading = useIsLoading();
83 |
84 | const track = () => client.track(key);
85 | const requestFeedback = (opts: RequestFlagFeedbackOptions) =>
86 | client.requestFeedback({ ...opts, flagKey: key });
87 |
88 | const flag = ref(
89 | client.getFlag(key) || {
90 | isEnabled: false,
91 | config: { key: undefined, payload: undefined },
92 | },
93 | );
94 |
95 | const updateFlag = () => {
96 | flag.value = client.getFlag(key);
97 | };
98 |
99 | onMounted(() => {
100 | updateFlag();
101 | });
102 |
103 | useOnEvent("flagsUpdated", updateFlag);
104 |
105 | return {
106 | key,
107 | isLoading,
108 | isEnabled: computed(() => flag.value.isEnabled),
109 | config: computed(() => flag.value.config),
110 | track,
111 | requestFeedback,
112 | } as TypedFlags[TKey];
113 | }
114 |
115 | /**
116 | * Vue composable for tracking custom events.
117 | *
118 | * This composable returns a function that can be used to track custom events
119 | * with the Reflag SDK.
120 | *
121 | * @example
122 | * ```ts
123 | * import { useTrack } from '@reflag/vue-sdk';
124 | *
125 | * const track = useTrack();
126 | *
127 | * // Track a custom event
128 | * track('button_clicked', { buttonName: 'Start Huddles' });
129 | * ```
130 | *
131 | * @returns A function that tracks an event. The function accepts:
132 | * - `eventName`: The name of the event to track.
133 | * - `attributes`: (Optional) Additional attributes to associate with the event.
134 | */
135 | export function useTrack() {
136 | const client = useClient();
137 | return (eventName: string, attributes?: Record<string, any> | null) =>
138 | client.track(eventName, attributes);
139 | }
140 |
141 | /**
142 | * Vue composable for requesting user feedback.
143 | *
144 | * This composable returns a function that can be used to trigger the feedback
145 | * collection flow with the Reflag SDK. You can use this to prompt users for
146 | * feedback at any point in your application.
147 | *
148 | * @example
149 | * ```ts
150 | * import { useRequestFeedback } from '@reflag/vue-sdk';
151 | *
152 | * const requestFeedback = useRequestFeedback();
153 | *
154 | * // Request feedback from the user
155 | * requestFeedback({
156 | * prompt: "How was your experience?",
157 | * metadata: { page: "dashboard" }
158 | * });
159 | * ```
160 | *
161 | * @returns A function that requests feedback from the user. The function accepts:
162 | * - `options`: An object containing feedback request options.
163 | */
164 | export function useRequestFeedback() {
165 | const client = useClient();
166 | return (options: RequestFeedbackData) => client.requestFeedback(options);
167 | }
168 |
169 | /**
170 | * Vue composable for sending feedback.
171 | *
172 | * This composable returns a function that can be used to send feedback to the
173 | * Reflag SDK. You can use this to send feedback from your application.
174 | *
175 | * @example
176 | * ```ts
177 | * import { useSendFeedback } from '@reflag/vue-sdk';
178 | *
179 | * const sendFeedback = useSendFeedback();
180 | *
181 | * // Send feedback from the user
182 | * sendFeedback({
183 | * feedback: "I love this flag!",
184 | * metadata: { page: "dashboard" }
185 | * });
186 | * ```
187 | *
188 | * @returns A function that sends feedback to the Reflag SDK. The function accepts:
189 | * - `options`: An object containing feedback options.
190 | */
191 | export function useSendFeedback() {
192 | const client = useClient();
193 | return (opts: UnassignedFeedback) => client.feedback(opts);
194 | }
195 |
196 | /**
197 | * Vue composable for updating the user context.
198 | *
199 | * This composable returns a function that can be used to update the user context
200 | * with the Reflag SDK. You can use this to update the user context at any point
201 | * in your application.
202 | *
203 | * @example
204 | * ```ts
205 | * import { useUpdateUser } from '@reflag/vue-sdk';
206 | *
207 | * const updateUser = useUpdateUser();
208 | *
209 | * // Update the user context
210 | * updateUser({ id: "123", name: "John Doe" });
211 | * ```
212 | *
213 | * @returns A function that updates the user context. The function accepts:
214 | * - `opts`: An object containing the user context to update.
215 | */
216 | export function useUpdateUser() {
217 | const client = useClient();
218 | return (opts: { [key: string]: string | number | undefined }) =>
219 | client.updateUser(opts);
220 | }
221 |
222 | /**
223 | * Vue composable for updating the company context.
224 | *
225 | * This composable returns a function that can be used to update the company
226 | * context with the Reflag SDK. You can use this to update the company context
227 | * at any point in your application.
228 | *
229 | * @example
230 | * ```ts
231 | * import { useUpdateCompany } from '@reflag/vue-sdk';
232 | *
233 | * const updateCompany = useUpdateCompany();
234 | *
235 | * // Update the company context
236 | * updateCompany({ id: "123", name: "Acme Inc." });
237 | * ```
238 | *
239 | * @returns A function that updates the company context. The function accepts:
240 | * - `opts`: An object containing the company context to update.
241 | */
242 | export function useUpdateCompany() {
243 | const client = useClient();
244 | return (opts: { [key: string]: string | number | undefined }) =>
245 | client.updateCompany(opts);
246 | }
247 |
248 | /**
249 | * Vue composable for updating the other context.
250 | *
251 | * This composable returns a function that can be used to update the other
252 | * context with the Reflag SDK. You can use this to update the other context
253 | * at any point in your application.
254 | *
255 | * @example
256 | * ```ts
257 | * import { useUpdateOtherContext } from '@reflag/vue-sdk';
258 | *
259 | * const updateOtherContext = useUpdateOtherContext();
260 | *
261 | * // Update the other context
262 | * updateOtherContext({ id: "123", name: "Acme Inc." });
263 | * ```
264 | *
265 | * @returns A function that updates the other context. The function accepts:
266 | * - `opts`: An object containing the other context to update.
267 | */
268 | export function useUpdateOtherContext() {
269 | const client = useClient();
270 | return (opts: { [key: string]: string | number | undefined }) =>
271 | client.updateOtherContext(opts);
272 | }
273 |
274 | /**
275 | * Vue composable for getting the Reflag client.
276 | *
277 | * This composable returns the Reflag client. You can use this to get the Reflag
278 | * client at any point in your application.
279 | *
280 | * @example
281 | * ```ts
282 | * import { useClient } from '@reflag/vue-sdk';
283 | *
284 | * const client = useClient();
285 | *
286 | * console.log(client.getContext());
287 | * ```
288 | * @returns The Reflag client.
289 | */
290 | export function useClient() {
291 | const ctx = injectSafe();
292 | return ctx.client;
293 | }
294 |
295 | /**
296 | * Vue composable for checking if the Reflag client is loading.
297 | *
298 | * This composable returns a boolean value that indicates whether the Reflag client is loading.
299 | * You can use this to check if the Reflag client is loading at any point in your application.
300 | * Initially, the value will be true until the client is initialized.
301 | *
302 | * @example
303 | * ```ts
304 | * import { useIsLoading } from '@reflag/vue-sdk';
305 | *
306 | * const isLoading = useIsLoading();
307 | *
308 | * console.log(isLoading);
309 | * ```
310 | */
311 | export function useIsLoading() {
312 | const ctx = injectSafe();
313 | return ctx.isLoading;
314 | }
315 |
316 | /**
317 | * Vue composable for listening to Reflag client events.
318 | *
319 | * @example
320 | * ```ts
321 | * import { useOnEvent } from '@reflag/vue-sdk';
322 | *
323 | * useOnEvent("flagsUpdated", () => {
324 | * console.log("flags updated");
325 | * });
326 | * ```
327 | *
328 | * @param event - The event to listen to.
329 | * @param handler - The function to call when the event is triggered.
330 | * @param client - The Reflag client to listen to. If not provided, the client will be retrieved from the context.
331 | */
332 | export function useOnEvent<THookType extends keyof HookArgs>(
333 | event: THookType,
334 | handler: (arg0: HookArgs[THookType]) => void,
335 | client?: ReflagClient,
336 | ) {
337 | const resolvedClient = client ?? useClient();
338 | let off: () => void;
339 | onMounted(() => {
340 | off = resolvedClient.on(event, handler);
341 | });
342 | onUnmounted(() => {
343 | off();
344 | });
345 | }
346 |
347 | function injectSafe() {
348 | const ctx = inject(ProviderSymbol);
349 | if (!ctx) {
350 | throw new Error(
351 | `ReflagProvider is missing. Please ensure your component is wrapped with a ReflagProvider.`,
352 | );
353 | }
354 | return ctx;
355 | }
356 |
```
--------------------------------------------------------------------------------
/packages/openfeature-browser-provider/src/index.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Client, OpenFeature } from "@openfeature/web-sdk";
2 | import { beforeEach, describe, expect, it, Mock, vi } from "vitest";
3 |
4 | import { ReflagClient } from "@reflag/browser-sdk";
5 |
6 | import { defaultContextTranslator, ReflagBrowserSDKProvider } from ".";
7 |
8 | vi.mock("@reflag/browser-sdk", () => {
9 | const actualModule = vi.importActual("@reflag/browser-sdk");
10 |
11 | return {
12 | __esModule: true,
13 | ...actualModule,
14 | ReflagClient: vi.fn(),
15 | };
16 | });
17 |
18 | const testFlagKey = "a-key";
19 |
20 | const publishableKey = "your-publishable-key";
21 |
22 | describe("ReflagBrowserSDKProvider", () => {
23 | let provider: ReflagBrowserSDKProvider;
24 | let ofClient: Client;
25 | const reflagClientMock = {
26 | getFlags: vi.fn(),
27 | getFlag: vi.fn(),
28 | initialize: vi.fn().mockResolvedValue({}),
29 | track: vi.fn(),
30 | stop: vi.fn(),
31 | };
32 |
33 | const mockReflagClient = ReflagClient as Mock;
34 | mockReflagClient.mockReturnValue(reflagClientMock);
35 |
36 | beforeEach(async () => {
37 | await OpenFeature.clearProviders();
38 |
39 | provider = new ReflagBrowserSDKProvider({ publishableKey });
40 | OpenFeature.setProvider(provider);
41 | ofClient = OpenFeature.getClient();
42 | });
43 |
44 | beforeEach(() => {
45 | vi.clearAllMocks();
46 | });
47 |
48 | const contextTranslatorFn = vi.fn();
49 |
50 | describe("lifecycle", () => {
51 | it("should call initialize function with correct arguments", async () => {
52 | await provider.initialize();
53 | expect(ReflagClient).toHaveBeenCalledTimes(1);
54 | expect(ReflagClient).toHaveBeenCalledWith({
55 | publishableKey,
56 | });
57 | expect(reflagClientMock.initialize).toHaveBeenCalledTimes(1);
58 | });
59 |
60 | it("should set the status to READY if initialization succeeds", async () => {
61 | reflagClientMock.initialize.mockReturnValue(Promise.resolve());
62 | await provider.initialize();
63 | expect(reflagClientMock.initialize).toHaveBeenCalledTimes(1);
64 | expect(provider.status).toBe("READY");
65 | });
66 |
67 | it("should call stop function when provider is closed", async () => {
68 | await OpenFeature.clearProviders();
69 | expect(reflagClientMock.stop).toHaveBeenCalledTimes(1);
70 | });
71 |
72 | it("onContextChange re-initializes client", async () => {
73 | const p = new ReflagBrowserSDKProvider({ publishableKey });
74 | expect(p["_client"]).toBeUndefined();
75 | expect(mockReflagClient).toHaveBeenCalledTimes(0);
76 |
77 | await p.onContextChange({}, {});
78 | expect(mockReflagClient).toHaveBeenCalledTimes(1);
79 | expect(p["_client"]).toBeDefined();
80 | });
81 | });
82 |
83 | describe("contextTranslator", () => {
84 | it("uses contextTranslatorFn if provided", async () => {
85 | const ofContext = {
86 | userId: "123",
87 | email: "[email protected]",
88 | avatar: "https://reflag.com/avatar.png",
89 | groupId: "456",
90 | groupName: "reflag",
91 | groupAvatar: "https://reflag.com/group-avatar.png",
92 | groupPlan: "pro",
93 | };
94 |
95 | const reflagContext = {
96 | user: {
97 | id: "123",
98 | name: "John Doe",
99 | email: "[email protected]",
100 | avatar: "https://acme.com/avatar.png",
101 | },
102 | company: {
103 | id: "456",
104 | name: "Acme, Inc.",
105 | plan: "pro",
106 | avatar: "https://acme.com/company-avatar.png",
107 | },
108 | };
109 |
110 | contextTranslatorFn.mockReturnValue(reflagContext);
111 | provider = new ReflagBrowserSDKProvider({
112 | publishableKey,
113 | contextTranslator: contextTranslatorFn,
114 | });
115 |
116 | await provider.initialize(ofContext);
117 |
118 | expect(contextTranslatorFn).toHaveBeenCalledWith(ofContext);
119 | expect(mockReflagClient).toHaveBeenCalledWith({
120 | publishableKey,
121 | ...reflagContext,
122 | });
123 | });
124 |
125 | it("defaultContextTranslator provides the correct context", async () => {
126 | expect(
127 | defaultContextTranslator({
128 | userId: 123,
129 | name: "John Doe",
130 | email: "[email protected]",
131 | avatar: "https://reflag.com/avatar.png",
132 | companyId: "456",
133 | companyName: "Acme, Inc.",
134 | companyAvatar: "https://acme.com/company-avatar.png",
135 | companyPlan: "pro",
136 | }),
137 | ).toEqual({
138 | user: {
139 | id: "123",
140 | name: "John Doe",
141 | email: "[email protected]",
142 | avatar: "https://reflag.com/avatar.png",
143 | },
144 | company: {
145 | id: "456",
146 | name: "Acme, Inc.",
147 | plan: "pro",
148 | avatar: "https://acme.com/company-avatar.png",
149 | },
150 | });
151 | });
152 |
153 | it("defaultContextTranslator uses targetingKey if provided", async () => {
154 | expect(
155 | defaultContextTranslator({
156 | targetingKey: "123",
157 | }),
158 | ).toMatchObject({
159 | user: {
160 | id: "123",
161 | },
162 | company: {
163 | id: undefined,
164 | },
165 | });
166 | });
167 | });
168 |
169 | describe("resolving flags", () => {
170 | beforeEach(async () => {
171 | await provider.initialize();
172 | });
173 |
174 | function mockFlag(
175 | enabled: boolean,
176 | configKey?: string | null,
177 | configPayload?: any,
178 | ) {
179 | const config = {
180 | key: configKey,
181 | payload: configPayload,
182 | };
183 |
184 | reflagClientMock.getFlag = vi.fn().mockReturnValue({
185 | isEnabled: enabled,
186 | config,
187 | });
188 |
189 | reflagClientMock.getFlags = vi.fn().mockReturnValue({
190 | [testFlagKey]: {
191 | isEnabled: enabled,
192 | config: {
193 | key: "key",
194 | payload: configPayload,
195 | },
196 | },
197 | });
198 | }
199 |
200 | it("returns error if provider is not initialized", async () => {
201 | await OpenFeature.clearProviders();
202 |
203 | const val = ofClient.getBooleanDetails(testFlagKey, true);
204 |
205 | expect(val).toMatchObject({
206 | flagKey: testFlagKey,
207 | flagMetadata: {},
208 | reason: "ERROR",
209 | errorCode: "PROVIDER_NOT_READY",
210 | value: true,
211 | });
212 | });
213 |
214 | it("returns error if flag is not found", async () => {
215 | mockFlag(true, "key", true);
216 | const val = ofClient.getBooleanDetails("missing-key", true);
217 |
218 | expect(val).toMatchObject({
219 | flagKey: "missing-key",
220 | flagMetadata: {},
221 | reason: "ERROR",
222 | errorCode: "FLAG_NOT_FOUND",
223 | value: true,
224 | });
225 | });
226 |
227 | it("calls the client correctly when evaluating", async () => {
228 | mockFlag(true, "key", true);
229 |
230 | const val = ofClient.getBooleanDetails(testFlagKey, false);
231 |
232 | expect(val).toMatchObject({
233 | flagKey: testFlagKey,
234 | flagMetadata: {},
235 | reason: "TARGETING_MATCH",
236 | variant: "key",
237 | value: true,
238 | });
239 |
240 | expect(reflagClientMock.getFlags).toHaveBeenCalled();
241 | expect(reflagClientMock.getFlag).toHaveBeenCalledWith(testFlagKey);
242 | });
243 |
244 | it.each([
245 | [true, false, true, "TARGETING_MATCH", undefined],
246 | [undefined, true, true, "ERROR", "FLAG_NOT_FOUND"],
247 | [undefined, false, false, "ERROR", "FLAG_NOT_FOUND"],
248 | ])(
249 | "should return the correct result when evaluating boolean. enabled: %s, value: %s, default: %s, expected: %s, reason: %s, errorCode: %s`",
250 | (enabled, def, expected, reason, errorCode) => {
251 | const configKey = enabled !== undefined ? "variant-1" : undefined;
252 | const flagKey = enabled ? testFlagKey : "missing-key";
253 |
254 | mockFlag(enabled ?? false, configKey);
255 |
256 | expect(ofClient.getBooleanDetails(flagKey, def)).toMatchObject({
257 | flagKey,
258 | flagMetadata: {},
259 | reason,
260 | value: expected,
261 | ...(errorCode ? { errorCode } : {}),
262 | ...(configKey ? { variant: configKey } : {}),
263 | });
264 | },
265 | );
266 |
267 | it("should return error when evaluating number", async () => {
268 | expect(ofClient.getNumberDetails(testFlagKey, 1)).toMatchObject({
269 | flagKey: testFlagKey,
270 | flagMetadata: {},
271 | reason: "ERROR",
272 | errorCode: "GENERAL",
273 | value: 1,
274 | });
275 | });
276 |
277 | it.each([
278 | ["key-1", "default", "key-1", "TARGETING_MATCH"],
279 | [null, "default", "default", "DEFAULT"],
280 | [undefined, "default", "default", "DEFAULT"],
281 | ])(
282 | "should return the correct result when evaluating string. variant: %s, def: %s, expected: %s, reason: %s, errorCode: %s`",
283 | (variant, def, expected, reason) => {
284 | mockFlag(true, variant, {});
285 | expect(ofClient.getStringDetails(testFlagKey, def)).toMatchObject({
286 | flagKey: testFlagKey,
287 | flagMetadata: {},
288 | reason,
289 | value: expected,
290 | ...(variant ? { variant } : {}),
291 | });
292 | },
293 | );
294 |
295 | it.each([
296 | ["one", {}, { a: 1 }, {}, "TARGETING_MATCH", undefined],
297 | ["two", "string", "default", "string", "TARGETING_MATCH", undefined],
298 | ["three", 15, 16, 15, "TARGETING_MATCH", undefined],
299 | ["four", true, true, true, "TARGETING_MATCH", undefined],
300 | ["five", 100, "string", "string", "ERROR", "TYPE_MISMATCH"],
301 | ["six", 1337, true, true, "ERROR", "TYPE_MISMATCH"],
302 | ["seven", "string", 1337, 1337, "ERROR", "TYPE_MISMATCH"],
303 | [undefined, null, { a: 2 }, { a: 2 }, "ERROR", "TYPE_MISMATCH"],
304 | [undefined, undefined, "a", "a", "ERROR", "TYPE_MISMATCH"],
305 | ])(
306 | "should return the correct result when evaluating object. variant: %s, value: %s, default: %s, expected: %s, reason: %s, errorCode: %s`",
307 | (variant, value, def, expected, reason, errorCode) => {
308 | mockFlag(true, variant, value);
309 |
310 | expect(ofClient.getObjectDetails(testFlagKey, def)).toMatchObject({
311 | flagKey: testFlagKey,
312 | flagMetadata: {},
313 | reason,
314 | value: expected,
315 | ...(errorCode ? { errorCode } : {}),
316 | ...(variant && !errorCode ? { variant } : {}),
317 | });
318 | },
319 | );
320 | });
321 |
322 | describe("track", () => {
323 | it("calls the client correctly for track calls", async () => {
324 | const testEvent = "testEvent";
325 | await provider.initialize();
326 |
327 | ofClient.track(testEvent, { key: "value" });
328 | expect(reflagClientMock.track).toHaveBeenCalled();
329 | expect(reflagClientMock.track).toHaveBeenCalledWith(testEvent, {
330 | key: "value",
331 | });
332 | });
333 | });
334 | });
335 |
```
--------------------------------------------------------------------------------
/packages/openfeature-node-provider/src/index.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { ProviderStatus } from "@openfeature/server-sdk";
2 | import { beforeEach, describe, expect, it, Mock, vi } from "vitest";
3 |
4 | import { ReflagClient } from "@reflag/node-sdk";
5 |
6 | import { defaultContextTranslator, ReflagNodeProvider } from "./index";
7 |
8 | vi.mock("@reflag/node-sdk", () => {
9 | const actualModule = vi.importActual("@reflag/node-sdk");
10 |
11 | return {
12 | __esModule: true,
13 | ...actualModule,
14 | ReflagClient: vi.fn(),
15 | };
16 | });
17 |
18 | const reflagClientMock = {
19 | getFlag: vi.fn(),
20 | getFlagDefinitions: vi.fn().mockReturnValue([]),
21 | initialize: vi.fn().mockResolvedValue({}),
22 | flush: vi.fn(),
23 | track: vi.fn(),
24 | };
25 |
26 | const secretKey = "sec_fakeSecretKey______"; // must be 23 characters long
27 |
28 | const context = {
29 | targetingKey: "abc",
30 | name: "John Doe",
31 | email: "[email protected]",
32 | };
33 |
34 | const reflagContext = {
35 | user: { id: "42" },
36 | company: { id: "99" },
37 | };
38 |
39 | const testFlagKey = "a-key";
40 |
41 | beforeEach(() => {
42 | vi.clearAllMocks();
43 | });
44 |
45 | describe("ReflagNodeProvider", () => {
46 | let provider: ReflagNodeProvider;
47 |
48 | const mockReflagClient = ReflagClient as Mock;
49 | mockReflagClient.mockReturnValue(reflagClientMock);
50 |
51 | let mockTranslatorFn: Mock;
52 |
53 | function mockFlag(
54 | enabled: boolean,
55 | configKey?: string | null,
56 | configPayload?: any,
57 | flagKey = testFlagKey,
58 | ) {
59 | const config = {
60 | key: configKey,
61 | payload: configPayload,
62 | };
63 |
64 | reflagClientMock.getFlag = vi.fn().mockReturnValue({
65 | isEnabled: enabled,
66 | config,
67 | });
68 |
69 | // Mock getFlagDefinitions to return feature definitions that include the specified flag
70 | reflagClientMock.getFlagDefinitions = vi.fn().mockReturnValue([
71 | {
72 | key: flagKey,
73 | description: "Test flag",
74 | flag: {},
75 | config: {},
76 | },
77 | ]);
78 | }
79 |
80 | beforeEach(async () => {
81 | mockTranslatorFn = vi.fn().mockReturnValue(reflagContext);
82 |
83 | provider = new ReflagNodeProvider({
84 | secretKey,
85 | contextTranslator: mockTranslatorFn,
86 | });
87 |
88 | await provider.initialize();
89 | });
90 |
91 | describe("contextTranslator", () => {
92 | it("defaultContextTranslator provides the correct context", async () => {
93 | expect(
94 | defaultContextTranslator({
95 | userId: 123,
96 | name: "John Doe",
97 | email: "[email protected]",
98 | avatar: "https://reflag.com/avatar.png",
99 | companyId: "456",
100 | companyName: "Acme, Inc.",
101 | companyAvatar: "https://acme.com/company-avatar.png",
102 | companyPlan: "pro",
103 | }),
104 | ).toEqual({
105 | user: {
106 | id: "123",
107 | name: "John Doe",
108 | email: "[email protected]",
109 | avatar: "https://reflag.com/avatar.png",
110 | },
111 | company: {
112 | id: "456",
113 | name: "Acme, Inc.",
114 | plan: "pro",
115 | avatar: "https://acme.com/company-avatar.png",
116 | },
117 | });
118 | });
119 |
120 | it("defaultContextTranslator uses targetingKey if provided", async () => {
121 | expect(
122 | defaultContextTranslator({
123 | targetingKey: "123",
124 | }),
125 | ).toMatchObject({
126 | user: {
127 | id: "123",
128 | },
129 | company: {
130 | id: undefined,
131 | },
132 | });
133 | });
134 | });
135 |
136 | describe("lifecycle", () => {
137 | it("calls the constructor of ReflagClient", () => {
138 | mockReflagClient.mockClear();
139 |
140 | provider = new ReflagNodeProvider({
141 | secretKey,
142 | contextTranslator: mockTranslatorFn,
143 | });
144 |
145 | expect(mockReflagClient).toHaveBeenCalledTimes(1);
146 | expect(mockReflagClient).toHaveBeenCalledWith({ secretKey });
147 | });
148 |
149 | it("should set the status to READY if initialization succeeds", async () => {
150 | provider = new ReflagNodeProvider({
151 | secretKey,
152 | contextTranslator: mockTranslatorFn,
153 | });
154 |
155 | await provider.initialize();
156 |
157 | expect(provider.status).toBe(ProviderStatus.READY);
158 | });
159 |
160 | it("should keep the status as READY after closing", async () => {
161 | provider = new ReflagNodeProvider({
162 | secretKey: "invalid",
163 | contextTranslator: mockTranslatorFn,
164 | });
165 |
166 | await provider.initialize();
167 | await provider.onClose();
168 |
169 | expect(provider.status).toBe(ProviderStatus.READY);
170 | });
171 |
172 | it("calls flush when provider is closed", async () => {
173 | await provider.onClose();
174 | expect(reflagClientMock.flush).toHaveBeenCalledTimes(1);
175 | });
176 |
177 | it("uses the contextTranslator function", async () => {
178 | mockFlag(true);
179 |
180 | await provider.resolveBooleanEvaluation(testFlagKey, false, context);
181 |
182 | expect(mockTranslatorFn).toHaveBeenCalledTimes(1);
183 | expect(mockTranslatorFn).toHaveBeenCalledWith(context);
184 |
185 | expect(reflagClientMock.getFlagDefinitions).toHaveBeenCalledTimes(1);
186 | expect(reflagClientMock.getFlag).toHaveBeenCalledWith(
187 | reflagContext,
188 | testFlagKey,
189 | );
190 | });
191 | });
192 |
193 | describe("resolving flags", () => {
194 | beforeEach(async () => {
195 | await provider.initialize();
196 | });
197 |
198 | it("returns error if provider is not initialized", async () => {
199 | provider = new ReflagNodeProvider({
200 | secretKey: "invalid",
201 | contextTranslator: mockTranslatorFn,
202 | });
203 |
204 | const val = await provider.resolveBooleanEvaluation(
205 | testFlagKey,
206 | true,
207 | context,
208 | );
209 |
210 | expect(val).toMatchObject({
211 | reason: "ERROR",
212 | errorCode: "PROVIDER_NOT_READY",
213 | value: true,
214 | });
215 | });
216 |
217 | it("returns error if flag is not found", async () => {
218 | mockFlag(true, "key", true);
219 | const val = await provider.resolveBooleanEvaluation(
220 | "missing-key",
221 | true,
222 | context,
223 | );
224 |
225 | expect(val).toMatchObject({
226 | reason: "ERROR",
227 | errorCode: "FLAG_NOT_FOUND",
228 | value: true,
229 | });
230 | });
231 |
232 | it("calls the client correctly when evaluating", async () => {
233 | mockFlag(true, "key", true);
234 |
235 | const val = await provider.resolveBooleanEvaluation(
236 | testFlagKey,
237 | false,
238 | context,
239 | );
240 |
241 | expect(val).toMatchObject({
242 | reason: "TARGETING_MATCH",
243 | value: true,
244 | });
245 |
246 | expect(reflagClientMock.getFlagDefinitions).toHaveBeenCalled();
247 | expect(reflagClientMock.getFlag).toHaveBeenCalledWith(
248 | reflagContext,
249 | testFlagKey,
250 | );
251 | });
252 |
253 | it.each([
254 | [true, false, true, "TARGETING_MATCH", undefined],
255 | [undefined, true, true, "ERROR", "FLAG_NOT_FOUND"],
256 | [undefined, false, false, "ERROR", "FLAG_NOT_FOUND"],
257 | ])(
258 | "should return the correct result when evaluating boolean. enabled: %s, value: %s, default: %s, expected: %s, reason: %s, errorCode: %s`",
259 | async (enabled, def, expected, reason, errorCode) => {
260 | const configKey = enabled !== undefined ? "variant-1" : undefined;
261 |
262 | mockFlag(enabled ?? false, configKey);
263 | const flagKey = enabled ? testFlagKey : "missing-key";
264 |
265 | expect(
266 | await provider.resolveBooleanEvaluation(flagKey, def, context),
267 | ).toMatchObject({
268 | reason,
269 | value: expected,
270 | ...(configKey ? { variant: configKey } : {}),
271 | ...(errorCode ? { errorCode } : {}),
272 | });
273 | },
274 | );
275 |
276 | it("should return error when context is missing user ID", async () => {
277 | mockTranslatorFn.mockReturnValue({ user: {} });
278 |
279 | expect(
280 | await provider.resolveBooleanEvaluation(testFlagKey, true, context),
281 | ).toMatchObject({
282 | reason: "ERROR",
283 | errorCode: "INVALID_CONTEXT",
284 | value: true,
285 | });
286 | });
287 |
288 | it("should return error when evaluating number", async () => {
289 | expect(
290 | await provider.resolveNumberEvaluation(testFlagKey, 1),
291 | ).toMatchObject({
292 | reason: "ERROR",
293 | errorCode: "GENERAL",
294 | value: 1,
295 | });
296 | });
297 |
298 | it.each([
299 | ["key-1", "default", "key-1", "TARGETING_MATCH"],
300 | [null, "default", "default", "DEFAULT"],
301 | [undefined, "default", "default", "DEFAULT"],
302 | ])(
303 | "should return the correct result when evaluating string. variant: %s, def: %s, expected: %s, reason: %s, errorCode: %s`",
304 | async (variant, def, expected, reason) => {
305 | mockFlag(true, variant, {});
306 | expect(
307 | await provider.resolveStringEvaluation(testFlagKey, def, context),
308 | ).toMatchObject({
309 | reason,
310 | value: expected,
311 | ...(variant ? { variant } : {}),
312 | });
313 | },
314 | );
315 |
316 | it.each([
317 | [{}, { a: 1 }, {}, "TARGETING_MATCH", undefined],
318 | ["string", "default", "string", "TARGETING_MATCH", undefined],
319 | [15, -15, 15, "TARGETING_MATCH", undefined],
320 | [true, false, true, "TARGETING_MATCH", undefined],
321 | [null, { a: 2 }, { a: 2 }, "ERROR", "TYPE_MISMATCH"],
322 | [100, "string", "string", "ERROR", "TYPE_MISMATCH"],
323 | [true, 1337, 1337, "ERROR", "TYPE_MISMATCH"],
324 | ["string", 1337, 1337, "ERROR", "TYPE_MISMATCH"],
325 | [undefined, "default", "default", "ERROR", "TYPE_MISMATCH"],
326 | ])(
327 | "should return the correct result when evaluating object. payload: %s, default: %s, expected: %s, reason: %s, errorCode: %s`",
328 | async (value, def, expected, reason, errorCode) => {
329 | const configKey = value === undefined ? undefined : "config-key";
330 | mockFlag(true, configKey, value);
331 | expect(
332 | await provider.resolveObjectEvaluation(testFlagKey, def, context),
333 | ).toMatchObject({
334 | reason,
335 | value: expected,
336 | ...(errorCode ? { errorCode, variant: configKey } : {}),
337 | });
338 | },
339 | );
340 | });
341 |
342 | describe("track", () => {
343 | it("should track", async () => {
344 | expect(mockTranslatorFn).toHaveBeenCalledTimes(0);
345 | provider.track("event", context, {
346 | action: "click",
347 | });
348 |
349 | expect(mockTranslatorFn).toHaveBeenCalledTimes(1);
350 | expect(mockTranslatorFn).toHaveBeenCalledWith(context);
351 | expect(reflagClientMock.track).toHaveBeenCalledTimes(1);
352 | expect(reflagClientMock.track).toHaveBeenCalledWith("42", "event", {
353 | attributes: { action: "click" },
354 | companyId: reflagContext.company.id,
355 | });
356 | });
357 | });
358 | });
359 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/src/feedback/feedback.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { HttpClient } from "../httpClient";
2 | import { Logger } from "../logger";
3 | import { AblySSEChannel, openAblySSEChannel } from "../sse";
4 | import { Position } from "../ui/types";
5 |
6 | import {
7 | FeedbackSubmission,
8 | FeedbackTranslations,
9 | OpenFeedbackFormOptions,
10 | } from "./ui/types";
11 | import {
12 | FeedbackPromptCompletionHandler,
13 | parsePromptMessage,
14 | processPromptMessage,
15 | } from "./prompts";
16 | import { getAuthToken } from "./promptStorage";
17 | import * as feedbackLib from "./ui";
18 | import { DEFAULT_POSITION } from "./ui";
19 |
20 | export type Key = string;
21 |
22 | export type FeedbackOptions = {
23 | /**
24 | * Enables automatic feedback prompting if it's set up in Reflag
25 | */
26 | enableAutoFeedback?: boolean;
27 |
28 | /**
29 | *
30 | */
31 | autoFeedbackHandler?: FeedbackPromptHandler;
32 |
33 | /**
34 | * With these options you can override the look of the feedback prompt
35 | */
36 | ui?: {
37 | /**
38 | * Control the placement and behavior of the feedback form.
39 | */
40 | position?: Position;
41 |
42 | /**
43 | * Add your own custom translations for the feedback form.
44 | * Undefined translation keys fall back to english defaults.
45 | */
46 | translations?: Partial<FeedbackTranslations>;
47 | };
48 | };
49 |
50 | export type RequestFeedbackData = Omit<
51 | OpenFeedbackFormOptions,
52 | "key" | "onSubmit"
53 | > & {
54 | /**
55 | * Company ID from your own application.
56 | */
57 | companyId?: string;
58 |
59 | /**
60 | * Allows you to handle a copy of the already submitted
61 | * feedback.
62 | *
63 | * This can be used for side effects, such as storing a
64 | * copy of the feedback in your own application or CRM.
65 | *
66 | * @param {Object} data
67 | */
68 | onAfterSubmit?: (data: FeedbackSubmission) => void;
69 |
70 | /**
71 | * Flag key.
72 | */
73 | flagKey: string;
74 | };
75 |
76 | export type RequestFeedbackOptions = RequestFeedbackData & {
77 | /**
78 | * User ID from your own application.
79 | */
80 | userId: string;
81 | };
82 |
83 | export type UnassignedFeedback = {
84 | /**
85 | * Flag key.
86 | */
87 | flagKey: string;
88 |
89 | /**
90 | * Reflag feedback ID
91 | */
92 | feedbackId?: string;
93 |
94 | /**
95 | * The question that was presented to the user.
96 | */
97 | question?: string;
98 |
99 | /**
100 | * The original question.
101 | * This only needs to be populated if the feedback was submitted through the automated feedback surveys channel.
102 | */
103 | promptedQuestion?: string;
104 |
105 | /**
106 | * Customer satisfaction score.
107 | */
108 | score?: number;
109 |
110 | /**
111 | * User supplied comment about your flag.
112 | */
113 | comment?: string;
114 |
115 | /**
116 | * Reflag feedback prompt ID.
117 | *
118 | * This only exists if the feedback was submitted
119 | * as part of an automated prompt from Reflag.
120 | *
121 | * Used for internal state management of automated
122 | * feedback.
123 | */
124 | promptId?: string;
125 |
126 | /**
127 | * Source of the feedback, depending on how the user was asked
128 | * - `prompt` - Feedback submitted by way of an automated feedback survey (prompted)
129 | * - `widget` - Feedback submitted via `requestFeedback`
130 | * - `sdk` - Feedback submitted via `feedback`
131 | */
132 | source?: "prompt" | "sdk" | "widget";
133 | };
134 |
135 | export type Feedback = UnassignedFeedback & {
136 | /**
137 | * User ID from your own application.
138 | */
139 | userId?: string;
140 |
141 | /**
142 | * Company ID from your own application.
143 | */
144 | companyId?: string;
145 | };
146 |
147 | export type FeedbackPrompt = {
148 | /**
149 | * Specific question user was asked
150 | */
151 | question: string;
152 |
153 | /**
154 | * Feedback prompt should appear only after this time
155 | */
156 | showAfter: Date;
157 |
158 | /**
159 | * Feedback prompt will not be shown after this time
160 | */
161 | showBefore: Date;
162 |
163 | /**
164 | * Id of the prompt
165 | */
166 | promptId: string;
167 |
168 | /**
169 | * Feature ID from Reflag
170 | */
171 | featureId: string;
172 | };
173 |
174 | export type FeedbackPromptReply = {
175 | question: string;
176 | companyId?: string;
177 | score?: number;
178 | comment?: string;
179 | };
180 |
181 | export type FeedbackPromptReplyHandler = <T extends FeedbackPromptReply | null>(
182 | reply: T,
183 | ) => T extends null ? Promise<void> : Promise<{ feedbackId: string }>;
184 |
185 | export type FeedbackPromptHandlerOpenFeedbackFormOptions = Omit<
186 | RequestFeedbackOptions,
187 | "featureId" | "flagKey" | "userId" | "companyId" | "onClose" | "onDismiss"
188 | >;
189 |
190 | export type FeedbackPromptHandlerCallbacks = {
191 | reply: FeedbackPromptReplyHandler;
192 | openFeedbackForm: (
193 | options: FeedbackPromptHandlerOpenFeedbackFormOptions,
194 | ) => void;
195 | };
196 |
197 | export type FeedbackPromptHandler = (
198 | prompt: FeedbackPrompt,
199 | handlers: FeedbackPromptHandlerCallbacks,
200 | ) => void;
201 |
202 | export const createDefaultFeedbackPromptHandler = (
203 | options: FeedbackPromptHandlerOpenFeedbackFormOptions = {},
204 | ): FeedbackPromptHandler => {
205 | return (_prompt: FeedbackPrompt, handlers) => {
206 | handlers.openFeedbackForm(options);
207 | };
208 | };
209 | export const DEFAULT_FEEDBACK_CONFIG = {
210 | promptHandler: createDefaultFeedbackPromptHandler(),
211 | feedbackPosition: DEFAULT_POSITION,
212 | translations: {},
213 | autoFeedbackEnabled: true,
214 | };
215 |
216 | // Payload can include featureId or flagKey, but the public API only exposes flagKey
217 | // We use featureId internally because prompting is based on featureId
218 | type FeedbackPayload = Omit<Feedback, "flagKey"> & {
219 | featureId?: string;
220 | flagKey?: string;
221 | };
222 |
223 | export async function feedback(
224 | httpClient: HttpClient,
225 | logger: Logger,
226 | payload: FeedbackPayload,
227 | ) {
228 | if (!payload.score && !payload.comment) {
229 | logger.error(
230 | "`feedback` call ignored, either `score` or `comment` must be provided",
231 | );
232 | return;
233 | }
234 |
235 | if (!payload.userId) {
236 | logger.error("`feedback` call ignored, no `userId` provided");
237 | return;
238 | }
239 |
240 | const featureId = "featureId" in payload ? payload.featureId : undefined;
241 | const flagKey = "flagKey" in payload ? payload.flagKey : undefined;
242 |
243 | if (!featureId && !flagKey) {
244 | logger.error(
245 | "`feedback` call ignored. Neither `featureId` nor `flagKey` have been provided",
246 | );
247 | return;
248 | }
249 |
250 | // set default source to sdk
251 | const feedbackPayload = {
252 | ...payload,
253 | flagKey: undefined,
254 | source: payload.source ?? "sdk",
255 | featureId,
256 | key: flagKey,
257 | };
258 |
259 | const res = await httpClient.post({
260 | path: `/feedback`,
261 | body: feedbackPayload,
262 | });
263 |
264 | logger.debug(`sent feedback`, res);
265 | return res;
266 | }
267 |
268 | export class AutoFeedback {
269 | private initialized = false;
270 | private sseChannel: AblySSEChannel | null = null;
271 |
272 | constructor(
273 | private sseBaseUrl: string,
274 | private logger: Logger,
275 | private httpClient: HttpClient,
276 | private feedbackPromptHandler: FeedbackPromptHandler = createDefaultFeedbackPromptHandler(),
277 | private userId: string,
278 | private position: Position = DEFAULT_POSITION,
279 | private feedbackTranslations: Partial<FeedbackTranslations> = {},
280 | ) {}
281 |
282 | /**
283 | * Start receiving automated feedback surveys.
284 | */
285 | async initialize() {
286 | if (this.initialized) {
287 | this.logger.warn("automatic feedback client already initialized");
288 | return;
289 | }
290 | this.initialized = true;
291 |
292 | const channel = await this.getChannel();
293 | if (!channel) return;
294 |
295 | try {
296 | this.logger.debug(`automatic feedback enabled`, channel);
297 | this.sseChannel = openAblySSEChannel({
298 | userId: this.userId,
299 | channel,
300 | httpClient: this.httpClient,
301 | callback: (message) =>
302 | this.handleFeedbackPromptRequest(this.userId, message),
303 | logger: this.logger,
304 | sseBaseUrl: this.sseBaseUrl,
305 | });
306 | this.logger.debug(`automatic feedback connection established`);
307 | } catch (e) {
308 | this.logger.error(`error initializing automatic feedback client`, e);
309 | }
310 | }
311 |
312 | stop() {
313 | if (this.sseChannel) {
314 | this.sseChannel.close();
315 | this.sseChannel = null;
316 | }
317 | }
318 |
319 | async setUser(userId: string) {
320 | this.stop();
321 | this.initialized = false;
322 | this.userId = userId;
323 | await this.initialize();
324 | }
325 |
326 | handleFeedbackPromptRequest(userId: string, message: any) {
327 | const parsed = parsePromptMessage(message);
328 | if (!parsed) {
329 | this.logger.error(`invalid feedback prompt message received`, message);
330 | } else {
331 | if (
332 | !processPromptMessage(userId, parsed, async (u, m, cb) => {
333 | await this.feedbackPromptEvent({
334 | promptId: parsed.promptId,
335 | featureId: parsed.featureId,
336 | promptedQuestion: parsed.question,
337 | event: "received",
338 | userId,
339 | });
340 | await this.triggerFeedbackPrompt(u, m, cb);
341 | })
342 | ) {
343 | this.logger.info(
344 | `feedback prompt not shown, it was either expired or already processed`,
345 | message,
346 | );
347 | }
348 | }
349 | }
350 |
351 | async triggerFeedbackPrompt(
352 | userId: string,
353 | message: FeedbackPrompt,
354 | completionHandler: FeedbackPromptCompletionHandler,
355 | ) {
356 | let feedbackId: string | undefined = undefined;
357 |
358 | await this.feedbackPromptEvent({
359 | promptId: message.promptId,
360 | featureId: message.featureId,
361 | promptedQuestion: message.question,
362 | event: "shown",
363 | userId,
364 | });
365 |
366 | const replyCallback: FeedbackPromptReplyHandler = async (reply) => {
367 | if (!reply) {
368 | await this.feedbackPromptEvent({
369 | promptId: message.promptId,
370 | featureId: message.featureId,
371 | event: "dismissed",
372 | userId,
373 | promptedQuestion: message.question,
374 | });
375 |
376 | completionHandler();
377 | return;
378 | }
379 |
380 | const feedbackPayload = {
381 | feedbackId: feedbackId,
382 | featureId: message.featureId,
383 | userId,
384 | companyId: reply.companyId,
385 | score: reply.score,
386 | comment: reply.comment,
387 | promptId: message.promptId,
388 | question: reply.question,
389 | promptedQuestion: message.question,
390 | source: "prompt",
391 | } satisfies FeedbackPayload;
392 |
393 | const response = await feedback(
394 | this.httpClient,
395 | this.logger,
396 | feedbackPayload,
397 | );
398 |
399 | completionHandler();
400 |
401 | if (response && response.ok) {
402 | return await response?.json();
403 | }
404 | return;
405 | };
406 |
407 | const handlers: FeedbackPromptHandlerCallbacks = {
408 | reply: replyCallback,
409 | openFeedbackForm: (options) => {
410 | feedbackLib.openFeedbackForm({
411 | key: message.featureId,
412 | title: message.question,
413 | onScoreSubmit: async (data) => {
414 | const res = await replyCallback(data);
415 | feedbackId = res.feedbackId;
416 | return { feedbackId: res.feedbackId };
417 | },
418 | onSubmit: async (data) => {
419 | await replyCallback(data);
420 | options.onAfterSubmit?.(data);
421 | },
422 | onDismiss: () => replyCallback(null),
423 | position: this.position,
424 | translations: this.feedbackTranslations,
425 | ...options,
426 | });
427 | },
428 | };
429 |
430 | this.feedbackPromptHandler(message, handlers);
431 | }
432 |
433 | async feedbackPromptEvent(args: {
434 | event: "received" | "shown" | "dismissed";
435 | featureId: string;
436 | promptId: string;
437 | promptedQuestion: string;
438 | userId: string;
439 | }) {
440 | const payload = {
441 | action: args.event,
442 | featureId: args.featureId,
443 | promptId: args.promptId,
444 | userId: args.userId,
445 | promptedQuestion: args.promptedQuestion,
446 | };
447 |
448 | const res = await this.httpClient.post({
449 | path: `/feedback/prompt-events`,
450 | body: payload,
451 | });
452 | this.logger.debug(`sent prompt event`, res);
453 | return res;
454 | }
455 |
456 | private async getChannel() {
457 | const existingAuth = getAuthToken(this.userId);
458 | const channel = existingAuth?.channel;
459 |
460 | if (channel) {
461 | return channel;
462 | }
463 |
464 | try {
465 | if (!channel) {
466 | const res = await this.httpClient.post({
467 | path: `/feedback/prompting-init`,
468 | body: {
469 | userId: this.userId,
470 | },
471 | });
472 |
473 | this.logger.debug(`automatic feedback status sent`, res);
474 | if (res.ok) {
475 | const body: { success: boolean; channel?: string } = await res.json();
476 | if (body.success && body.channel) {
477 | return body.channel;
478 | }
479 | }
480 | }
481 | } catch (e) {
482 | this.logger.error(`error initializing automatic feedback`, e);
483 | return;
484 | }
485 | return;
486 | }
487 | }
488 |
```
--------------------------------------------------------------------------------
/packages/cli/test/json.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, expect, it } from "vitest";
2 |
3 | import {
4 | JSONToType,
5 | mergeTypeASTs,
6 | quoteKey,
7 | stringifyTypeAST,
8 | toTypeAST,
9 | TypeAST,
10 | } from "../utils/json.js";
11 |
12 | describe("JSON utilities", () => {
13 | describe("toTypeAST", () => {
14 | it("should handle primitive values", () => {
15 | expect(toTypeAST("test")).toEqual({ kind: "primitive", type: "string" });
16 | expect(toTypeAST(42)).toEqual({ kind: "primitive", type: "number" });
17 | expect(toTypeAST(true)).toEqual({ kind: "primitive", type: "boolean" });
18 | expect(toTypeAST(null)).toEqual({ kind: "primitive", type: "null" });
19 | });
20 |
21 | it("should handle arrays", () => {
22 | expect(toTypeAST([1, 2, 3])).toEqual({
23 | kind: "array",
24 | elementType: { kind: "primitive", type: "number" },
25 | });
26 |
27 | expect(toTypeAST([])).toEqual({
28 | kind: "array",
29 | elementType: { kind: "primitive", type: "any" },
30 | });
31 | });
32 |
33 | it("should handle arrays with mixed element types", () => {
34 | expect(toTypeAST([1, "test", true])).toEqual({
35 | kind: "array",
36 | elementType: {
37 | kind: "union",
38 | types: [
39 | { kind: "primitive", type: "number" },
40 | { kind: "primitive", type: "string" },
41 | { kind: "primitive", type: "boolean" },
42 | ],
43 | },
44 | });
45 |
46 | expect(toTypeAST([{ name: "John" }, { age: 30 }])).toEqual({
47 | kind: "array",
48 | elementType: {
49 | kind: "object",
50 | properties: [
51 | {
52 | key: "name",
53 | type: { kind: "primitive", type: "string" },
54 | optional: true,
55 | },
56 | {
57 | key: "age",
58 | type: { kind: "primitive", type: "number" },
59 | optional: true,
60 | },
61 | ],
62 | },
63 | });
64 | });
65 |
66 | it("should handle objects", () => {
67 | expect(toTypeAST({ name: "John", age: 30 })).toEqual({
68 | kind: "object",
69 | properties: [
70 | {
71 | key: "name",
72 | type: { kind: "primitive", type: "string" },
73 | optional: false,
74 | },
75 | {
76 | key: "age",
77 | type: { kind: "primitive", type: "number" },
78 | optional: false,
79 | },
80 | ],
81 | });
82 | });
83 |
84 | it("should handle nested structures", () => {
85 | const input = {
86 | user: {
87 | name: "John",
88 | contacts: [{ email: "[email protected]" }],
89 | },
90 | };
91 |
92 | const expected: TypeAST = {
93 | kind: "object",
94 | properties: [
95 | {
96 | key: "user",
97 | type: {
98 | kind: "object",
99 | properties: [
100 | {
101 | key: "name",
102 | type: { kind: "primitive", type: "string" },
103 | optional: false,
104 | },
105 | {
106 | key: "contacts",
107 | type: {
108 | kind: "array",
109 | elementType: {
110 | kind: "object",
111 | properties: [
112 | {
113 | key: "email",
114 | type: { kind: "primitive", type: "string" },
115 | optional: false,
116 | },
117 | ],
118 | },
119 | },
120 | optional: false,
121 | },
122 | ],
123 | },
124 | optional: false,
125 | },
126 | ],
127 | };
128 |
129 | expect(toTypeAST(input)).toEqual(expected);
130 | });
131 | });
132 |
133 | describe("mergeTypeASTs", () => {
134 | it("should handle empty array", () => {
135 | expect(mergeTypeASTs([])).toEqual({ kind: "primitive", type: "any" });
136 | });
137 |
138 | it("should return the same AST for single item arrays", () => {
139 | const ast: TypeAST = { kind: "primitive", type: "string" };
140 | expect(mergeTypeASTs([ast])).toEqual(ast);
141 | });
142 |
143 | it("should merge same primitive types", () => {
144 | const types: TypeAST[] = [
145 | { kind: "primitive", type: "number" },
146 | { kind: "primitive", type: "number" },
147 | ];
148 | expect(mergeTypeASTs(types)).toEqual({
149 | kind: "primitive",
150 | type: "number",
151 | });
152 | });
153 |
154 | it("should create union for different primitive types", () => {
155 | const types: TypeAST[] = [
156 | { kind: "primitive", type: "string" },
157 | { kind: "primitive", type: "number" },
158 | ];
159 | expect(mergeTypeASTs(types)).toEqual({
160 | kind: "union",
161 | types: [
162 | { kind: "primitive", type: "string" },
163 | { kind: "primitive", type: "number" },
164 | ],
165 | });
166 | });
167 |
168 | it("should merge array types", () => {
169 | const types: TypeAST[] = [
170 | { kind: "array", elementType: { kind: "primitive", type: "number" } },
171 | { kind: "array", elementType: { kind: "primitive", type: "string" } },
172 | ];
173 | expect(mergeTypeASTs(types)).toEqual({
174 | kind: "array",
175 | elementType: {
176 | kind: "union",
177 | types: [
178 | { kind: "primitive", type: "number" },
179 | { kind: "primitive", type: "string" },
180 | ],
181 | },
182 | });
183 | });
184 |
185 | it("should merge object types and mark missing properties as optional", () => {
186 | const types: TypeAST[] = [
187 | {
188 | kind: "object",
189 | properties: [
190 | {
191 | key: "name",
192 | type: { kind: "primitive", type: "string" },
193 | optional: false,
194 | },
195 | {
196 | key: "age",
197 | type: { kind: "primitive", type: "number" },
198 | optional: false,
199 | },
200 | ],
201 | },
202 | {
203 | kind: "object",
204 | properties: [
205 | {
206 | key: "name",
207 | type: { kind: "primitive", type: "string" },
208 | optional: false,
209 | },
210 | {
211 | key: "email",
212 | type: { kind: "primitive", type: "string" },
213 | optional: false,
214 | },
215 | ],
216 | },
217 | ];
218 |
219 | expect(mergeTypeASTs(types)).toEqual({
220 | kind: "object",
221 | properties: [
222 | {
223 | key: "name",
224 | type: { kind: "primitive", type: "string" },
225 | optional: false,
226 | },
227 | {
228 | key: "age",
229 | type: { kind: "primitive", type: "number" },
230 | optional: true,
231 | },
232 | {
233 | key: "email",
234 | type: { kind: "primitive", type: "string" },
235 | optional: true,
236 | },
237 | ],
238 | });
239 | });
240 |
241 | it("should create union for mixed kinds", () => {
242 | const types: TypeAST[] = [
243 | { kind: "primitive", type: "string" },
244 | { kind: "array", elementType: { kind: "primitive", type: "number" } },
245 | ];
246 |
247 | expect(mergeTypeASTs(types)).toEqual({
248 | kind: "union",
249 | types,
250 | });
251 | });
252 | });
253 |
254 | describe("stringifyTypeAST", () => {
255 | it("should stringify primitive types", () => {
256 | expect(stringifyTypeAST({ kind: "primitive", type: "string" })).toBe(
257 | "string",
258 | );
259 | expect(stringifyTypeAST({ kind: "primitive", type: "number" })).toBe(
260 | "number",
261 | );
262 | expect(stringifyTypeAST({ kind: "primitive", type: "boolean" })).toBe(
263 | "boolean",
264 | );
265 | expect(stringifyTypeAST({ kind: "primitive", type: "null" })).toBe(
266 | "null",
267 | );
268 | });
269 |
270 | it("should stringify array types", () => {
271 | expect(
272 | stringifyTypeAST({
273 | kind: "array",
274 | elementType: { kind: "primitive", type: "string" },
275 | }),
276 | ).toBe("(string)[]");
277 | });
278 |
279 | it("should stringify object types", () => {
280 | const ast: TypeAST = {
281 | kind: "object",
282 | properties: [
283 | {
284 | key: "name",
285 | type: { kind: "primitive", type: "string" },
286 | optional: false,
287 | },
288 | {
289 | key: "age",
290 | type: { kind: "primitive", type: "number" },
291 | optional: true,
292 | },
293 | ],
294 | };
295 |
296 | const expected = `{\n name: string,\n age?: number\n}`;
297 | expect(stringifyTypeAST(ast)).toBe(expected);
298 | });
299 |
300 | it("should stringify empty objects", () => {
301 | expect(stringifyTypeAST({ kind: "object", properties: [] })).toBe("{}");
302 | });
303 |
304 | it("should quote object keys with special characters", () => {
305 | const ast: TypeAST = {
306 | kind: "object",
307 | properties: [
308 | {
309 | key: "my-key",
310 | type: { kind: "primitive", type: "string" },
311 | optional: false,
312 | },
313 | {
314 | key: "my key",
315 | type: { kind: "primitive", type: "string" },
316 | optional: false,
317 | },
318 | {
319 | key: "my.key",
320 | type: { kind: "primitive", type: "string" },
321 | optional: false,
322 | },
323 | {
324 | key: "123key",
325 | type: { kind: "primitive", type: "string" },
326 | optional: false,
327 | },
328 | ],
329 | };
330 |
331 | const expected = `{\n "my-key": string,\n "my key": string,\n "my.key": string,\n "123key": string\n}`;
332 | expect(stringifyTypeAST(ast)).toBe(expected);
333 | });
334 |
335 | it("should stringify union types", () => {
336 | const ast: TypeAST = {
337 | kind: "union",
338 | types: [
339 | { kind: "primitive", type: "string" },
340 | { kind: "primitive", type: "number" },
341 | ],
342 | };
343 |
344 | expect(stringifyTypeAST(ast)).toBe("string | number");
345 | });
346 |
347 | it("should handle complex nested types", () => {
348 | const ast: TypeAST = {
349 | kind: "object",
350 | properties: [
351 | {
352 | key: "user",
353 | type: {
354 | kind: "object",
355 | properties: [
356 | {
357 | key: "name",
358 | type: { kind: "primitive", type: "string" },
359 | optional: false,
360 | },
361 | {
362 | key: "contacts",
363 | type: {
364 | kind: "array",
365 | elementType: {
366 | kind: "object",
367 | properties: [
368 | {
369 | key: "email",
370 | type: { kind: "primitive", type: "string" },
371 | optional: false,
372 | },
373 | ],
374 | },
375 | },
376 | optional: false,
377 | },
378 | ],
379 | },
380 | optional: false,
381 | },
382 | ],
383 | };
384 |
385 | const expected =
386 | `{\n user: {\n name: string,\n contacts: ({\n email: string\n })[]` +
387 | `\n }\n}`;
388 | expect(stringifyTypeAST(ast)).toBe(expected);
389 | });
390 | });
391 |
392 | describe("JSONToType", () => {
393 | it("should handle empty arrays", () => {
394 | expect(JSONToType([])).toBeNull();
395 | });
396 |
397 | it("should generate type for array of primitives", () => {
398 | expect(JSONToType([1, 2, 3])).toBe("number");
399 | expect(JSONToType(["a", "b", "c"])).toBe("string");
400 | expect(JSONToType([1, "a", true])).toBe("number | string | boolean");
401 | });
402 |
403 | it("should handle arrays with simple mixed element types", () => {
404 | const expected = "(number | string | boolean)[]";
405 | expect(JSONToType([["a", true], [1]])).toBe(expected);
406 | });
407 |
408 | it("should handle arrays with advanced mixed element types", () => {
409 | const expected =
410 | "(number | string | boolean | {\n id?: number,\n name?: string\n})[]";
411 | expect(
412 | JSONToType([
413 | [1, "a", true],
414 | [{ id: 1 }, { name: "test" }],
415 | ]),
416 | ).toBe(expected);
417 | });
418 |
419 | it("should merge arrays with nested arrays of mixed element types", () => {
420 | const expected = "((boolean | number | string | {\n id: number\n})[])[]";
421 | expect(JSONToType([[[1, "test"], [true]], [[{ id: 1 }]]])).toBe(expected);
422 | });
423 |
424 | it("should generate type for array of objects", () => {
425 | const expected = `{\n name: string,\n age?: number,\n email?: string\n}`;
426 | expect(
427 | JSONToType([
428 | { name: "John", age: 30 },
429 | { name: "Jane", email: "[email protected]" },
430 | ]),
431 | ).toBe(expected);
432 | });
433 |
434 | it("should handle complex nested structures", () => {
435 | const expected =
436 | `{\n user: {\n name: string,\n settings: {\n theme?: string,` +
437 | `\n notifications?: boolean\n }\n }\n}`;
438 |
439 | expect(
440 | JSONToType([
441 | {
442 | user: {
443 | name: "John",
444 | settings: { theme: "dark" },
445 | },
446 | },
447 | {
448 | user: {
449 | name: "Jane",
450 | settings: { notifications: true },
451 | },
452 | },
453 | ]),
454 | ).toBe(expected);
455 | });
456 | });
457 |
458 | describe("quoteKey", () => {
459 | it("should quote keys with special characters", () => {
460 | expect(quoteKey("my-key")).toBe('"my-key"');
461 | expect(quoteKey("my key")).toBe('"my key"');
462 | expect(quoteKey("my.key")).toBe('"my.key"');
463 | expect(quoteKey("123key")).toBe('"123key"');
464 | expect(quoteKey("key")).toBe("key");
465 | });
466 | });
467 | });
468 |
```