This is page 3 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
--------------------------------------------------------------------------------
/.github/workflows/package-ci.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: "Package CI"
2 |
3 | permissions:
4 | statuses: write
5 | checks: write
6 | contents: read
7 |
8 | on: [push]
9 |
10 | jobs:
11 | build-and-test:
12 | name: Build & Test
13 | runs-on: ubuntu-22.04
14 | steps:
15 | - name: Checkout source code
16 | uses: actions/checkout@v4
17 | - name: Enable corepack
18 | run: corepack enable
19 | - name: Use Node.js
20 | uses: actions/setup-node@v4
21 | with:
22 | node-version-file: ".nvmrc"
23 | cache: "yarn"
24 | cache-dependency-path: "**/yarn.lock"
25 | - name: Restore Node.js dependencies
26 | run: yarn install --immutable
27 | - name: Restore package.json
28 | # This step is necessary because the previous step may have updated package.json
29 | run: git checkout -- package.json packages/*/package.json
30 | - name: Install Playwright Browsers
31 | run: yarn playwright install --with-deps
32 | working-directory: ./packages/browser-sdk
33 | - id: build
34 | name: Build the project
35 | run: yarn build
36 | - name: Build docs
37 | run: yarn docs
38 | - id: test
39 | name: Test the project
40 | run: yarn test:ci
41 | - name: Report test results
42 | uses: dorny/[email protected]
43 | with:
44 | name: Build & Test Report
45 | path: ./packages/*/junit.xml
46 | reporter: jest-junit
47 | - id: prettier
48 | name: Check styling
49 | run: yarn prettier
50 | - id: lint
51 | name: Check for linting errors
52 | run: yarn lint:ci
53 | - name: Annotate from ESLint report
54 | uses: ataylorme/eslint-annotate-action@v2
55 | with:
56 | repo-token: "${{ secrets.GITHUB_TOKEN }}"
57 | report-json: ./packages/*/eslint-report.json
58 | fail-on-warning: true
59 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/src/toolbar/Flags.css:
--------------------------------------------------------------------------------
```css
1 | .search-input {
2 | background: transparent;
3 | border: none;
4 | color: white;
5 | width: 100%;
6 | font-size: var(--text-size);
7 | height: 28px;
8 |
9 | &::placeholder {
10 | color: var(--gray500);
11 | }
12 |
13 | &::-webkit-search-cancel-button {
14 | -webkit-appearance: none;
15 | display: inline-block;
16 | width: 8px;
17 | height: 8px;
18 | margin-left: 10px;
19 | background:
20 | linear-gradient(
21 | 45deg,
22 | rgba(0, 0, 0, 0) 0%,
23 | rgba(0, 0, 0, 0) 43%,
24 | #fff 45%,
25 | #fff 55%,
26 | rgba(0, 0, 0, 0) 57%,
27 | rgba(0, 0, 0, 0) 100%
28 | ),
29 | linear-gradient(
30 | 135deg,
31 | transparent 0%,
32 | transparent 43%,
33 | #fff 45%,
34 | #fff 55%,
35 | transparent 57%,
36 | transparent 100%
37 | );
38 | cursor: pointer;
39 | }
40 | }
41 |
42 | .flags-table {
43 | width: 100%;
44 | border-collapse: collapse;
45 | }
46 |
47 | .flag-row {
48 | &.not-visible {
49 | visibility: hidden;
50 | }
51 | }
52 |
53 | .flags-table-empty {
54 | position: absolute;
55 | top: 0;
56 | left: 0;
57 | right: 0;
58 | color: var(--gray500);
59 | padding: 12px 12px;
60 | line-height: 1.5;
61 | }
62 |
63 | .flag-name-cell {
64 | white-space: nowrap;
65 | overflow: hidden;
66 | text-overflow: ellipsis;
67 | width: auto;
68 | padding: 6px 6px 6px 0;
69 | display: flex;
70 | align-items: center;
71 | gap: 8px;
72 |
73 | .flag-icon {
74 | height: 15px;
75 | width: 15px;
76 | color: var(--dimmed-color);
77 | }
78 | }
79 |
80 | .flag-link {
81 | color: var(--text-color);
82 | text-decoration: none;
83 |
84 | &:hover,
85 | &:focus-visible {
86 | text-decoration: underline;
87 | }
88 | }
89 |
90 | .flag-reset-cell {
91 | width: 32px;
92 | padding: 6px 0;
93 | text-align: right;
94 | }
95 |
96 | .reset {
97 | color: var(--gray500);
98 | text-decoration: none;
99 |
100 | &:hover,
101 | &:focus-visible {
102 | text-decoration: underline;
103 | }
104 | }
105 |
106 | .flag-switch-cell {
107 | padding: 6px 0 6px 6px;
108 | width: 0;
109 | }
110 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/src/feedback/prompts.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { FeedbackPrompt } from "./feedback";
2 | import {
3 | checkPromptMessageCompleted,
4 | markPromptMessageCompleted,
5 | } from "./promptStorage";
6 |
7 | export const parsePromptMessage = (
8 | message: any,
9 | ): FeedbackPrompt | undefined => {
10 | if (
11 | typeof message?.question !== "string" ||
12 | !message.question.length ||
13 | typeof message.showAfter !== "number" ||
14 | typeof message.showBefore !== "number" ||
15 | typeof message.promptId !== "string" ||
16 | !message.promptId.length ||
17 | typeof message.featureId !== "string" ||
18 | !message.featureId.length
19 | ) {
20 | return undefined;
21 | } else {
22 | return {
23 | question: message.question,
24 | showAfter: new Date(message.showAfter),
25 | showBefore: new Date(message.showBefore),
26 | promptId: message.promptId,
27 | featureId: message.featureId,
28 | };
29 | }
30 | };
31 |
32 | export type FeedbackPromptCompletionHandler = () => void;
33 | export type FeedbackPromptDisplayHandler = (
34 | userId: string,
35 | prompt: FeedbackPrompt,
36 | completionHandler: FeedbackPromptCompletionHandler,
37 | ) => void;
38 |
39 | export const processPromptMessage = (
40 | userId: string,
41 | prompt: FeedbackPrompt,
42 | displayHandler: FeedbackPromptDisplayHandler,
43 | ) => {
44 | const now = new Date();
45 |
46 | const completionHandler = () => {
47 | markPromptMessageCompleted(userId, prompt.promptId, prompt.showBefore);
48 | };
49 |
50 | if (checkPromptMessageCompleted(userId, prompt.promptId)) {
51 | return false;
52 | } else if (now > prompt.showBefore) {
53 | return false;
54 | } else if (now < prompt.showAfter) {
55 | setTimeout(() => {
56 | displayHandler(userId, prompt, completionHandler);
57 | }, prompt.showAfter.getTime() - now.getTime());
58 |
59 | return true;
60 | } else {
61 | displayHandler(userId, prompt, completionHandler);
62 | return true;
63 | }
64 | };
65 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/test/flagCache.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import {
2 | afterAll,
3 | beforeEach,
4 | describe,
5 | expect,
6 | test,
7 | vi,
8 | vitest,
9 | } from "vitest";
10 |
11 | import { CacheResult, FlagCache } from "../src/flag/flagCache";
12 |
13 | beforeEach(() => {
14 | vi.useFakeTimers();
15 | vi.resetAllMocks();
16 | });
17 |
18 | afterAll(() => {
19 | vi.useRealTimers();
20 | });
21 |
22 | export const TEST_STALE_MS = 1000;
23 | export const TEST_EXPIRE_MS = 2000;
24 |
25 | export function newCache(): {
26 | cache: FlagCache;
27 | cacheItem: (string | null)[];
28 | } {
29 | const cacheItem: (string | null)[] = [null];
30 | return {
31 | cache: new FlagCache({
32 | storage: {
33 | get: () => cacheItem[0],
34 | set: (value) => (cacheItem[0] = value),
35 | },
36 | staleTimeMs: TEST_STALE_MS,
37 | expireTimeMs: TEST_EXPIRE_MS,
38 | }),
39 | cacheItem,
40 | };
41 | }
42 |
43 | describe("cache", () => {
44 | const flags = {
45 | flagA: { isEnabled: true, key: "flagA", targetingVersion: 1 },
46 | };
47 |
48 | test("caches items", async () => {
49 | const { cache } = newCache();
50 |
51 | cache.set("key", { flags });
52 | expect(cache.get("key")).toEqual({
53 | stale: false,
54 | flags,
55 | } satisfies CacheResult);
56 | });
57 |
58 | test("sets stale", async () => {
59 | const { cache } = newCache();
60 |
61 | cache.set("key", { flags });
62 |
63 | vitest.advanceTimersByTime(TEST_STALE_MS + 1);
64 |
65 | const cacheItem = cache.get("key");
66 | expect(cacheItem?.stale).toBe(true);
67 | });
68 |
69 | test("expires on set", async () => {
70 | const { cache, cacheItem } = newCache();
71 |
72 | cache.set("first key", {
73 | flags,
74 | });
75 | expect(cacheItem[0]).not.toBeNull();
76 | vitest.advanceTimersByTime(TEST_EXPIRE_MS + 1);
77 |
78 | cache.set("other key", {
79 | flags,
80 | });
81 |
82 | const item = cache.get("key");
83 | expect(item).toBeUndefined();
84 | expect(cacheItem[0]).not.toContain("first key"); // should have been removed
85 | });
86 | });
87 |
```
--------------------------------------------------------------------------------
/packages/node-sdk/src/inRequestCache.ts:
--------------------------------------------------------------------------------
```typescript
1 | // This is a cache that is updated as part of the request/response cycle.
2 | // This is useful in serverless runtimes where `setTimeout` doesn't exist or does something useless.
3 |
4 | import { Logger } from "./types";
5 |
6 | export default function inRequestCache<T>(
7 | ttl: number,
8 | logger: Logger | undefined,
9 | fn: () => Promise<T | undefined>,
10 | ) {
11 | let value: T | undefined = undefined;
12 | let lastFetch = 0;
13 | let fetching: Promise<void> | null = null;
14 |
15 | async function refresh(): Promise<T | undefined> {
16 | if (!fetching) {
17 | fetching = (async () => {
18 | try {
19 | const result = await fn();
20 | logger?.debug("inRequestCache: fetched value", result);
21 | if (result !== undefined) {
22 | value = result;
23 | }
24 | } catch (err) {
25 | if (logger) {
26 | logger.error?.("inRequestCache: error refreshing value", err);
27 | }
28 | } finally {
29 | lastFetch = Date.now();
30 | fetching = null;
31 | }
32 | })();
33 | }
34 | await fetching;
35 | return value;
36 | }
37 |
38 | const waitRefresh = async () => {
39 | if (fetching) await fetching;
40 | };
41 |
42 | const destroy = () => {
43 | fetching = null;
44 | value = undefined;
45 | lastFetch = 0;
46 | };
47 |
48 | return {
49 | get(): T | undefined {
50 | const now = Date.now();
51 | // If value is undefined, just return undefined
52 | if (value === undefined) {
53 | return undefined;
54 | }
55 | // If value is stale, trigger background refresh
56 | if (now - lastFetch > ttl) {
57 | logger?.debug(
58 | "inRequestCache: stale value, triggering background refresh",
59 | );
60 | void refresh();
61 | }
62 |
63 | return value;
64 | },
65 | async refresh(): Promise<T | undefined> {
66 | return await refresh();
67 | },
68 | waitRefresh,
69 | destroy,
70 | };
71 | }
72 |
```
--------------------------------------------------------------------------------
/packages/vue-sdk/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "@reflag/vue-sdk",
3 | "version": "1.2.0",
4 | "license": "MIT",
5 | "repository": {
6 | "type": "git",
7 | "url": "https://github.com/reflagcom/javascript.git"
8 | },
9 | "publishConfig": {
10 | "access": "public"
11 | },
12 | "scripts": {
13 | "dev": "vite",
14 | "build": "tsc --project tsconfig.build.json && vite build",
15 | "test": "vitest run",
16 | "test:watch": "vitest",
17 | "test:ci": "yarn test --reporter=default --reporter=junit --outputFile=junit.xml",
18 | "coverage": "yarn test --coverage",
19 | "lint": "eslint .",
20 | "lint:ci": "eslint --output-file eslint-report.json --format json .",
21 | "prettier": "prettier --check .",
22 | "format": "yarn lint --fix && yarn prettier --write",
23 | "preversion": "yarn lint && yarn prettier && yarn test && yarn build"
24 | },
25 | "files": [
26 | "dist"
27 | ],
28 | "main": "./dist/reflag-vue-sdk.umd.js",
29 | "types": "./dist/index.d.ts",
30 | "exports": {
31 | ".": {
32 | "import": "./dist/reflag-vue-sdk.mjs",
33 | "require": "./dist/reflag-vue-sdk.umd.js",
34 | "types": "./dist/index.d.ts"
35 | }
36 | },
37 | "dependencies": {
38 | "@reflag/browser-sdk": "1.2.0"
39 | },
40 | "peerDependencies": {
41 | "vue": "^3.0.0"
42 | },
43 | "devDependencies": {
44 | "@reflag/eslint-config": "^0.0.2",
45 | "@reflag/tsconfig": "^0.0.2",
46 | "@types/jsdom": "^21.1.6",
47 | "@types/node": "^22.12.0",
48 | "@vitejs/plugin-vue": "^5.2.4",
49 | "@vue/test-utils": "^2.3.2",
50 | "eslint": "^9.21.0",
51 | "eslint-plugin-vue": "^9.28.0",
52 | "jsdom": "^24.1.0",
53 | "msw": "^2.3.5",
54 | "prettier": "^3.5.2",
55 | "rollup": "^4.2.0",
56 | "rollup-preserve-directives": "^1.1.2",
57 | "ts-node": "^10.9.2",
58 | "typescript": "^5.7.3",
59 | "vite": "^5.0.13",
60 | "vite-plugin-dts": "^4.5.4",
61 | "vitest": "^2.0.4",
62 | "vue": "^3.5.16",
63 | "vue-eslint-parser": "^9.4.2"
64 | }
65 | }
66 |
```
--------------------------------------------------------------------------------
/packages/cli/commands/auth.ts:
--------------------------------------------------------------------------------
```typescript
1 | import chalk from "chalk";
2 | import { Command } from "commander";
3 | import ora from "ora";
4 |
5 | import { authStore } from "../stores/auth.js";
6 | import { configStore } from "../stores/config.js";
7 | import { waitForAccessToken } from "../utils/auth.js";
8 | import { handleError } from "../utils/errors.js";
9 |
10 | export const loginAction = async () => {
11 | const { baseUrl, apiUrl } = configStore.getConfig();
12 | const { token, isApiKey } = authStore.getToken(baseUrl);
13 |
14 | if (isApiKey) {
15 | handleError(
16 | "Login is not allowed when an API token was supplied.",
17 | "Login",
18 | );
19 | }
20 |
21 | if (token) {
22 | console.log("Already logged in, nothing to do.");
23 | return;
24 | }
25 |
26 | try {
27 | const { accessToken } = await waitForAccessToken(baseUrl, apiUrl);
28 | await authStore.setToken(baseUrl, accessToken);
29 |
30 | console.log(`Logged in to ${chalk.cyan(baseUrl)} successfully!`);
31 | } catch (error) {
32 | console.error("Login failed.");
33 | handleError(error, "Login");
34 | }
35 | };
36 |
37 | export const logoutAction = async () => {
38 | const baseUrl = configStore.getConfig("baseUrl");
39 |
40 | const { token, isApiKey } = authStore.getToken(baseUrl);
41 |
42 | if (isApiKey) {
43 | handleError(
44 | "Logout is not allowed when an API token was supplied.",
45 | "Logout",
46 | );
47 | }
48 |
49 | if (!token) {
50 | console.log("Not logged in, nothing to do.");
51 | return;
52 | }
53 |
54 | const spinner = ora("Logging out...").start();
55 |
56 | try {
57 | await authStore.setToken(baseUrl, null);
58 |
59 | spinner.succeed("Logged out successfully!");
60 | } catch (error) {
61 | spinner.fail("Logout failed.");
62 | handleError(error, "Logout");
63 | }
64 | };
65 |
66 | export function registerAuthCommands(cli: Command) {
67 | cli.command("login").description("Login to Reflag.").action(loginAction);
68 |
69 | cli.command("logout").description("Logout from Reflag.").action(logoutAction);
70 | }
71 |
```
--------------------------------------------------------------------------------
/packages/cli/services/bootstrap.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { authRequest } from "../utils/auth.js";
2 | import { KeyFormat } from "../utils/gen.js";
3 |
4 | export type Environment = {
5 | id: string;
6 | name: string;
7 | isProduction: boolean;
8 | order: number;
9 | };
10 |
11 | export type App = {
12 | id: string;
13 | name: string;
14 | demo: boolean;
15 | environments: Environment[];
16 | };
17 |
18 | export type ReflagUser = {
19 | id: string;
20 | email: string;
21 | name: string;
22 | };
23 |
24 | export type Org = {
25 | id: string;
26 | name: string;
27 | apps: App[];
28 | featureKeyFormat: KeyFormat;
29 | };
30 |
31 | export type BootstrapResponse = {
32 | org: Org;
33 | user: ReflagUser;
34 | };
35 |
36 | let bootstrapResponse: BootstrapResponse | null = null;
37 |
38 | export async function bootstrap(): Promise<BootstrapResponse> {
39 | if (!bootstrapResponse) {
40 | bootstrapResponse = await authRequest<BootstrapResponse>(`/bootstrap`);
41 | }
42 | return bootstrapResponse;
43 | }
44 |
45 | export function getOrg(): Org {
46 | if (!bootstrapResponse) {
47 | throw new Error("CLI has not been bootstrapped.");
48 | }
49 | if (!bootstrapResponse.org) {
50 | throw new Error("No organization found.");
51 | }
52 | return bootstrapResponse.org;
53 | }
54 |
55 | export function listApps(): App[] {
56 | if (!bootstrapResponse) {
57 | throw new Error("CLI has not been bootstrapped.");
58 | }
59 | const org = bootstrapResponse.org;
60 | if (!org) {
61 | throw new Error("No organization found.");
62 | }
63 | if (!org.apps?.length) {
64 | throw new Error("No apps found.");
65 | }
66 | return bootstrapResponse.org.apps;
67 | }
68 |
69 | export function getApp(id: string): App {
70 | const apps = listApps();
71 | const app = apps.find((a) => a.id === id);
72 | if (!app) {
73 | throw new Error(`App with id ${id} not found`);
74 | }
75 | return app;
76 | }
77 |
78 | export function getReflagUser(): ReflagUser {
79 | if (!bootstrapResponse) {
80 | throw new Error("CLI has not been bootstrapped.");
81 | }
82 | if (!bootstrapResponse.user) {
83 | throw new Error("No user found.");
84 | }
85 | return bootstrapResponse.user;
86 | }
87 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/src/ui/icons/Dissatisfied.tsx:
--------------------------------------------------------------------------------
```typescript
1 | import { FunctionComponent, h } from "preact";
2 |
3 | export const Dissatisfied: FunctionComponent<
4 | h.JSX.SVGAttributes<SVGSVGElement>
5 | > = (props) => (
6 | <svg
7 | fill="none"
8 | height="22"
9 | viewBox="0 0 24 24"
10 | width="22"
11 | xmlns="http://www.w3.org/2000/svg"
12 | {...props}
13 | >
14 | <path
15 | d="M12 22C6.477 22 2 17.523 2 12C2 6.477 6.477 2 12 2C17.523 2 22 6.477 22 12C22 17.523 17.523 22 12 22ZM12 20C14.1217 20 16.1566 19.1571 17.6569 17.6569C19.1571 16.1566 20 14.1217 20 12C20 9.87827 19.1571 7.84344 17.6569 6.34315C16.1566 4.84285 14.1217 4 12 4C9.87827 4 7.84344 4.84285 6.34315 6.34315C4.84285 7.84344 4 9.87827 4 12C4 14.1217 4.84285 16.1566 6.34315 17.6569C7.84344 19.1571 9.87827 20 12 20Z"
16 | fill="currentColor"
17 | />
18 | <path
19 | d="M12 15C13.1835 15 14.2712 15.4113 15.1279 16.0991C15.4665 16.3709 15.4398 16.8738 15.1187 17.166C14.8962 17.3685 14.5788 17.4225 14.2915 17.3332C13.605 17.1198 12.8259 17 12 17C11.1741 17 10.3949 17.1205 9.70841 17.3331C9.42116 17.422 9.104 17.3677 8.88161 17.1653C8.56025 16.8728 8.53318 16.3695 8.87206 16.0976C9.2087 15.8274 9.57986 15.6014 9.97666 15.426C10.6139 15.1442 11.3032 14.9991 12 15ZM8.5 10C8.89783 10 9.27936 10.158 9.56066 10.4393C9.84197 10.7206 10 11.1022 10 11.5C10 11.8978 9.84197 12.2794 9.56066 12.5607C9.27936 12.842 8.89783 13 8.5 13C8.10218 13 7.72065 12.842 7.43934 12.5607C7.15804 12.2794 7 11.8978 7 11.5C7 11.1022 7.15804 10.7206 7.43934 10.4393C7.72065 10.158 8.10218 10 8.5 10ZM15.5 10C15.8978 10 16.2794 10.158 16.5607 10.4393C16.842 10.7206 17 11.1022 17 11.5C17 11.8978 16.842 12.2794 16.5607 12.5607C16.2794 12.842 15.8978 13 15.5 13C15.1022 13 14.7206 12.842 14.4393 12.5607C14.158 12.2794 14 11.8978 14 11.5C14 11.1022 14.158 10.7206 14.4393 10.4393C14.7206 10.158 15.1022 10 15.5 10Z"
20 | fill="currentColor"
21 | />
22 | </svg>
23 | );
24 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/src/ui/icons/Satisfied.tsx:
--------------------------------------------------------------------------------
```typescript
1 | import { FunctionComponent, h } from "preact";
2 |
3 | export const Satisfied: FunctionComponent<
4 | h.JSX.SVGAttributes<SVGSVGElement>
5 | > = (props) => (
6 | <svg
7 | fill="none"
8 | height="22"
9 | viewBox="0 0 24 24"
10 | width="22"
11 | xmlns="http://www.w3.org/2000/svg"
12 | {...props}
13 | >
14 | <path
15 | d="M12 22C6.477 22 2 17.523 2 12C2 6.477 6.477 2 12 2C17.523 2 22 6.477 22 12C22 17.523 17.523 22 12 22ZM12 20C14.1217 20 16.1566 19.1571 17.6569 17.6569C19.1571 16.1566 20 14.1217 20 12C20 9.87827 19.1571 7.84344 17.6569 6.34315C16.1566 4.84285 14.1217 4 12 4C9.87827 4 7.84344 4.84285 6.34315 6.34315C4.84285 7.84344 4 9.87827 4 12C4 14.1217 4.84285 16.1566 6.34315 17.6569C7.84344 19.1571 9.87827 20 12 20ZM8 11C7.60217 11 7.22064 10.842 6.93934 10.5607C6.65803 10.2794 6.5 9.89782 6.5 9.5C6.5 9.10217 6.65803 8.72064 6.93934 8.43934C7.22064 8.15803 7.60217 8 8 8C8.39782 8 8.77935 8.15803 9.06066 8.43934C9.34196 8.72064 9.5 9.10217 9.5 9.5C9.5 9.89782 9.34196 10.2794 9.06066 10.5607C8.77935 10.842 8.39782 11 8 11ZM16 11C15.6022 11 15.2206 10.842 14.9393 10.5607C14.658 10.2794 14.5 9.89782 14.5 9.5C14.5 9.10217 14.658 8.72064 14.9393 8.43934C15.2206 8.15803 15.6022 8 16 8C16.3978 8 16.7794 8.15803 17.0607 8.43934C17.342 8.72064 17.5 9.10217 17.5 9.5C17.5 9.89782 17.342 10.2794 17.0607 10.5607C16.7794 10.842 16.3978 11 16 11Z"
16 | fill="currentColor"
17 | />
18 | <path
19 | d="M7.79862 15.4322C8.85269 16.5065 10.4964 17.4971 11.9993 17.4971C13.5011 17.4971 15.1701 16.5079 16.2097 15.4351C16.5083 15.1269 16.4581 14.6416 16.1408 14.3528C15.9042 14.1375 15.5656 14.0777 15.2972 14.2517C14.5161 14.7578 13.4271 15.7002 11.9993 15.7002C10.5688 15.7002 9.47831 14.7549 8.69694 14.2486C8.43116 14.0764 8.09564 14.1353 7.86141 14.3485C7.5435 14.6378 7.49757 15.1254 7.79862 15.4322Z"
20 | fill="currentColor"
21 | />
22 | </svg>
23 | );
24 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/src/feedback/ui/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { h, render } from "preact";
2 |
3 | import { feedbackContainerId, propagatedEvents } from "../../ui/constants";
4 | import { Position } from "../../ui/types";
5 |
6 | import { FeedbackDialog } from "./FeedbackDialog";
7 | import { OpenFeedbackFormOptions } from "./types";
8 |
9 | export const DEFAULT_POSITION: Position = {
10 | type: "DIALOG",
11 | placement: "bottom-right",
12 | };
13 |
14 | function stopPropagation(e: Event) {
15 | e.stopPropagation();
16 | }
17 |
18 | function attachDialogContainer() {
19 | let container = document.querySelector(`#${feedbackContainerId}`);
20 |
21 | if (!container) {
22 | container = document.createElement("div");
23 | container.attachShadow({ mode: "open" });
24 | (container as HTMLElement).style.all = "initial";
25 | container.id = feedbackContainerId;
26 | document.body.appendChild(container);
27 |
28 | for (const event of propagatedEvents) {
29 | container.addEventListener(event, stopPropagation, { passive: true });
30 | }
31 | }
32 |
33 | return container.shadowRoot!;
34 | }
35 |
36 | // this is a counter that increases every time the feedback form is opened
37 | // and since it's passed as a key to the FeedbackDialog component,
38 | // it forces a re-render on every form open
39 | let openInstances = 0;
40 |
41 | export function openFeedbackForm(options: OpenFeedbackFormOptions): void {
42 | const shadowRoot = attachDialogContainer();
43 | const position = options.position || DEFAULT_POSITION;
44 |
45 | if (position.type === "POPOVER") {
46 | if (!position.anchor) {
47 | console.warn(
48 | "[Reflag]",
49 | "Unable to open popover. Anchor must be a defined DOM-element",
50 | );
51 | return;
52 | }
53 |
54 | if (!document.body.contains(position.anchor)) {
55 | console.warn(
56 | "[Reflag]",
57 | "Unable to open popover. Anchor must be an attached DOM-element",
58 | );
59 | return;
60 | }
61 | }
62 |
63 | openInstances++;
64 |
65 | render(
66 | h(FeedbackDialog, { ...options, position, key: openInstances.toString() }),
67 | shadowRoot,
68 | );
69 | }
70 |
```
--------------------------------------------------------------------------------
/packages/react-sdk/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "@reflag/react-sdk",
3 | "version": "1.2.0",
4 | "license": "MIT",
5 | "repository": {
6 | "type": "git",
7 | "url": "https://github.com/reflagcom/javascript.git"
8 | },
9 | "publishConfig": {
10 | "access": "public"
11 | },
12 | "scripts": {
13 | "dev": "vite",
14 | "build": "tsc --project tsconfig.build.json && vite build",
15 | "test": "vitest run",
16 | "test:watch": "vitest",
17 | "test:ci": "yarn test --reporter=default --reporter=junit --outputFile=junit.xml",
18 | "coverage": "yarn test --coverage",
19 | "lint": "eslint .",
20 | "lint:ci": "eslint --output-file eslint-report.json --format json .",
21 | "prettier": "prettier --check .",
22 | "format": "yarn lint --fix && yarn prettier --write",
23 | "preversion": "yarn lint && yarn prettier && yarn test && yarn build"
24 | },
25 | "files": [
26 | "dist"
27 | ],
28 | "main": "./dist/reflag-react-sdk.umd.js",
29 | "types": "./dist/index.d.ts",
30 | "exports": {
31 | ".": {
32 | "import": "./dist/reflag-react-sdk.mjs",
33 | "require": "./dist/reflag-react-sdk.umd.js",
34 | "types": "./dist/index.d.ts"
35 | }
36 | },
37 | "dependencies": {
38 | "@reflag/browser-sdk": "1.2.0"
39 | },
40 | "peerDependencies": {
41 | "react": "*",
42 | "react-dom": "*"
43 | },
44 | "devDependencies": {
45 | "@reflag/eslint-config": "^0.0.2",
46 | "@reflag/tsconfig": "^0.0.2",
47 | "@testing-library/react": "^15.0.7",
48 | "@types/jsdom": "^21.1.6",
49 | "@types/node": "^22.12.0",
50 | "@types/react": "^18.3.2",
51 | "@types/react-dom": "^18.3.0",
52 | "@types/webpack": "^5.28.5",
53 | "eslint": "^9.21.0",
54 | "jsdom": "^24.1.0",
55 | "msw": "^2.3.5",
56 | "prettier": "^3.5.2",
57 | "react": "*",
58 | "react-dom": "*",
59 | "rollup": "^4.2.0",
60 | "rollup-preserve-directives": "^1.1.2",
61 | "ts-node": "^10.9.2",
62 | "typescript": "^5.7.3",
63 | "vite": "^5.0.13",
64 | "vite-plugin-dts": "^4.0.0-beta.1",
65 | "vitest": "^2.0.4",
66 | "webpack": "^5.89.0",
67 | "webpack-cli": "^5.1.4"
68 | }
69 | }
70 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/src/ui/icons/VeryDissatisfied.tsx:
--------------------------------------------------------------------------------
```typescript
1 | import { FunctionComponent, h } from "preact";
2 |
3 | export const VeryDissatisfied: FunctionComponent<
4 | h.JSX.SVGAttributes<SVGSVGElement>
5 | > = (props) => (
6 | <svg
7 | fill="none"
8 | height="22"
9 | viewBox="0 0 24 24"
10 | width="22"
11 | xmlns="http://www.w3.org/2000/svg"
12 | {...props}
13 | >
14 | <path
15 | d="M12 22C6.477 22 2 17.523 2 12C2 6.477 6.477 2 12 2C17.523 2 22 6.477 22 12C22 17.523 17.523 22 12 22ZM12 20C14.1217 20 16.1566 19.1571 17.6569 17.6569C19.1571 16.1566 20 14.1217 20 12C20 9.87827 19.1571 7.84344 17.6569 6.34315C16.1566 4.84285 14.1217 4 12 4C9.87827 4 7.84344 4.84285 6.34315 6.34315C4.84285 7.84344 4 9.87827 4 12C4 14.1217 4.84285 16.1566 6.34315 17.6569C7.84344 19.1571 9.87827 20 12 20ZM8 17C7.44771 17 6.98992 16.5479 7.09965 16.0066C7.29346 15.0506 7.76447 14.1645 8.46447 13.4645C9.40215 12.5268 10.6739 12 12 12C13.3261 12 14.5979 12.5268 15.5355 13.4645C16.2355 14.1645 16.7065 15.0506 16.9003 16.0066C17.0101 16.5479 16.5523 17 16 17V17C15.4477 17 15.0156 16.5403 14.8349 16.0184C14.6877 15.5934 14.4454 15.2028 14.1213 14.8787C13.5587 14.3161 12.7956 14 12 14C11.2043 14 10.4413 14.3161 9.87868 14.8787C9.55459 15.2028 9.31232 15.5934 9.16513 16.0184C8.98442 16.5403 8.55228 17 8 17V17ZM8 11C7.60217 11 7.22064 10.842 6.93934 10.5607C6.65803 10.2794 6.5 9.89782 6.5 9.5C6.5 9.10217 6.65803 8.72064 6.93934 8.43934C7.22064 8.15803 7.60217 8 8 8C8.39782 8 8.77935 8.15803 9.06066 8.43934C9.34196 8.72064 9.5 9.10217 9.5 9.5C9.5 9.89782 9.34196 10.2794 9.06066 10.5607C8.77935 10.842 8.39782 11 8 11ZM16 11C15.6022 11 15.2206 10.842 14.9393 10.5607C14.658 10.2794 14.5 9.89782 14.5 9.5C14.5 9.10217 14.658 8.72064 14.9393 8.43934C15.2206 8.15803 15.6022 8 16 8C16.3978 8 16.7794 8.15803 17.0607 8.43934C17.342 8.72064 17.5 9.10217 17.5 9.5C17.5 9.89782 17.342 10.2794 17.0607 10.5607C16.7794 10.842 16.3978 11 16 11Z"
16 | fill="currentColor"
17 | />
18 | </svg>
19 | );
20 |
```
--------------------------------------------------------------------------------
/packages/cli/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "@reflag/cli",
3 | "version": "1.0.4",
4 | "packageManager": "[email protected]",
5 | "description": "CLI for Reflag service",
6 | "main": "./dist/index.js",
7 | "type": "module",
8 | "license": "MIT",
9 | "author": "Reflag.",
10 | "homepage": "https://docs.reflag.com/",
11 | "repository": {
12 | "type": "git",
13 | "url": "https://github.com/reflagcom/javascript.git"
14 | },
15 | "publishConfig": {
16 | "access": "public"
17 | },
18 | "engines": {
19 | "node": ">=18.0.0"
20 | },
21 | "bin": {
22 | "reflag": "./dist/index.js"
23 | },
24 | "files": [
25 | "dist",
26 | "schema.json"
27 | ],
28 | "exports": {
29 | ".": "./dist/index.js"
30 | },
31 | "scripts": {
32 | "build": "tsc && shx chmod +x dist/index.js",
33 | "reflag": "yarn build && ./dist/index.js",
34 | "test": "vitest run",
35 | "test:watch": "vitest",
36 | "test:ci": "yarn test --reporter=default --reporter=junit --outputFile=junit.xml",
37 | "coverage": "vitest run --coverage",
38 | "lint": "eslint .",
39 | "lint:ci": "eslint --output-file eslint-report.json --format json .",
40 | "prettier": "prettier --check .",
41 | "format": "yarn lint --fix && yarn prettier --write",
42 | "preversion": "yarn lint && yarn prettier && yarn vitest run -c vite.config.js && yarn build"
43 | },
44 | "dependencies": {
45 | "@inquirer/prompts": "^7.9.0",
46 | "ajv": "^8.17.1",
47 | "chalk": "^5.3.0",
48 | "change-case": "^5.4.4",
49 | "commander": "^12.1.0",
50 | "comment-json": "^4.2.5",
51 | "express": "^4.21.2",
52 | "fast-deep-equal": "^3.1.3",
53 | "find-up": "^7.0.0",
54 | "open": "^10.1.0",
55 | "ora": "^8.1.0",
56 | "semver": "^7.7.2",
57 | "slug": "^10.0.0",
58 | "zod": "^3.24.2"
59 | },
60 | "devDependencies": {
61 | "@reflag/eslint-config": "^0.0.2",
62 | "@reflag/tsconfig": "^0.0.2",
63 | "@types/express": "^5.0.0",
64 | "@types/node": "^22.5.1",
65 | "@types/semver": "^7.7.0",
66 | "@types/slug": "^5.0.9",
67 | "eslint": "^9.21.0",
68 | "prettier": "^3.5.2",
69 | "shx": "^0.3.4",
70 | "typescript": "^5.5.4",
71 | "vitest": "^3.0.8"
72 | }
73 | }
74 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/src/hooksManager.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { CheckEvent, RawFlags } from "./flag/flags";
2 | import { CompanyContext, UserContext } from "./context";
3 |
4 | /**
5 | * State of the client.
6 | */
7 | export type State = "idle" | "initializing" | "initialized" | "stopped";
8 |
9 | export interface HookArgs {
10 | stateUpdated: State;
11 | check: CheckEvent;
12 | flagsUpdated: RawFlags;
13 |
14 | /**
15 | * @deprecated Use `flagsUpdated` instead.
16 | */
17 | featuresUpdated: RawFlags;
18 | user: UserContext;
19 | company: CompanyContext;
20 | track: TrackEvent;
21 | }
22 |
23 | export type TrackEvent = {
24 | user: UserContext;
25 | company?: CompanyContext;
26 | eventName: string;
27 | attributes?: Record<string, any> | null;
28 | };
29 |
30 | /**
31 | * Hooks manager.
32 | * @internal
33 | */
34 | export class HooksManager {
35 | private hooks: {
36 | stateUpdated: ((arg0: State) => void)[];
37 | check: ((arg0: CheckEvent) => void)[];
38 | flagsUpdated: ((arg0: RawFlags) => void)[];
39 | user: ((arg0: UserContext) => void)[];
40 | company: ((arg0: CompanyContext) => void)[];
41 | track: ((arg0: TrackEvent) => void)[];
42 | } = {
43 | stateUpdated: [],
44 | check: [],
45 | flagsUpdated: [],
46 | user: [],
47 | company: [],
48 | track: [],
49 | };
50 |
51 | private _adjustEvent(event: keyof HookArgs) {
52 | return event === "featuresUpdated" ? "flagsUpdated" : event;
53 | }
54 |
55 | addHook<THookType extends keyof HookArgs>(
56 | event: THookType,
57 | cb: (arg0: HookArgs[THookType]) => void,
58 | ): () => void {
59 | (this.hooks[this._adjustEvent(event)] as any[]).push(cb);
60 | return () => {
61 | this.removeHook(event, cb);
62 | };
63 | }
64 |
65 | removeHook<THookType extends keyof HookArgs>(
66 | event: THookType,
67 | cb: (arg0: HookArgs[THookType]) => void,
68 | ): void {
69 | this.hooks[this._adjustEvent(event)] = this.hooks[
70 | this._adjustEvent(event)
71 | ].filter((hook) => hook !== cb) as any;
72 | }
73 |
74 | trigger<THookType extends keyof HookArgs>(
75 | event: THookType,
76 | arg: HookArgs[THookType],
77 | ): void {
78 | this.hooks[this._adjustEvent(event)].forEach((hook) => hook(arg as any));
79 | }
80 | }
81 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "@reflag/browser-sdk",
3 | "version": "1.2.0",
4 | "packageManager": "[email protected]",
5 | "license": "MIT",
6 | "repository": {
7 | "type": "git",
8 | "url": "https://github.com/reflagcom/javascript.git"
9 | },
10 | "publishConfig": {
11 | "access": "public"
12 | },
13 | "scripts": {
14 | "dev": "vite",
15 | "build": "tsc --project tsconfig.build.json && vite build",
16 | "test": "vitest run",
17 | "test:watch": "vitest",
18 | "test:e2e": "yarn build && playwright test",
19 | "test:ci": "yarn test --reporter=default --reporter=junit --outputFile=junit.xml && yarn test:e2e",
20 | "coverage": "yarn test --coverage",
21 | "lint": "eslint .",
22 | "lint:ci": "eslint --output-file eslint-report.json --format json .",
23 | "prettier": "prettier --check .",
24 | "format": "yarn lint --fix && yarn prettier --write",
25 | "preversion": "yarn lint && yarn prettier && yarn test && yarn build"
26 | },
27 | "files": [
28 | "dist"
29 | ],
30 | "main": "./dist/reflag-browser-sdk.umd.js",
31 | "types": "./dist/types/src/index.d.ts",
32 | "exports": {
33 | ".": {
34 | "import": "./dist/reflag-browser-sdk.mjs",
35 | "require": "./dist/reflag-browser-sdk.umd.js",
36 | "types": "./dist/types/src/index.d.ts"
37 | }
38 | },
39 | "dependencies": {
40 | "@floating-ui/dom": "^1.6.8",
41 | "fast-equals": "^5.2.2",
42 | "js-cookie": "^3.0.5",
43 | "preact": "^10.22.1"
44 | },
45 | "devDependencies": {
46 | "@playwright/test": "^1.49.1",
47 | "@reflag/eslint-config": "0.0.2",
48 | "@reflag/tsconfig": "0.0.2",
49 | "@types/js-cookie": "^3.0.6",
50 | "@types/node": "^22.12.0",
51 | "@vitest/coverage-v8": "^2.0.4",
52 | "c8": "~10.1.3",
53 | "eslint": "^9.21.0",
54 | "eslint-config-preact": "^1.5.0",
55 | "http-server": "^14.1.1",
56 | "jsdom": "^24.1.0",
57 | "msw": "^2.3.4",
58 | "nock": "^14.0.1",
59 | "postcss": "^8.4.33",
60 | "postcss-nesting": "^12.0.2",
61 | "postcss-preset-env": "^9.3.0",
62 | "prettier": "^3.5.2",
63 | "typescript": "^5.7.3",
64 | "vite": "^5.3.5",
65 | "vite-plugin-dts": "^4.0.0-beta.1",
66 | "vitest": "^2.0.4"
67 | }
68 | }
69 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/test/httpClient.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
2 |
3 | import { HttpClient } from "../src/httpClient";
4 |
5 | const cases = [
6 | ["https://front.reflag.com", "https://front.reflag.com/path"],
7 | ["https://front.reflag.com/", "https://front.reflag.com/path"],
8 | [
9 | "https://front.reflag.com/basepath",
10 | "https://front.reflag.com/basepath/path",
11 | ],
12 | ];
13 |
14 | test.each(cases)(
15 | "url construction with `/path`: %s -> %s",
16 | (base, expected) => {
17 | const client = new HttpClient("publishableKey", { baseUrl: base });
18 | expect(client.getUrl("/path").toString()).toBe(expected);
19 | },
20 | );
21 |
22 | test.each(cases)("url construction with `path`: %s -> %s", (base, expected) => {
23 | const client = new HttpClient("publishableKey", { baseUrl: base });
24 | expect(client.getUrl("path").toString()).toBe(expected);
25 | });
26 |
27 | describe("sets `credentials`", () => {
28 | beforeEach(() => {
29 | vi.spyOn(global, "fetch").mockResolvedValue(new Response());
30 | });
31 |
32 | afterEach(() => {
33 | vi.resetAllMocks();
34 | });
35 | test("default credentials", async () => {
36 | const client = new HttpClient("publishableKey");
37 |
38 | await client.get({ path: "/test" });
39 | expect(global.fetch).toHaveBeenCalledWith(
40 | expect.any(URL),
41 | expect.objectContaining({ credentials: undefined }),
42 | );
43 |
44 | await client.post({ path: "/test", body: {} });
45 | expect(global.fetch).toHaveBeenCalledWith(
46 | expect.any(URL),
47 | expect.objectContaining({ credentials: undefined }),
48 | );
49 | });
50 |
51 | test("custom credentials", async () => {
52 | const client = new HttpClient("publishableKey", { credentials: "include" });
53 |
54 | await client.get({ path: "/test" });
55 | expect(global.fetch).toHaveBeenCalledWith(
56 | expect.any(URL),
57 | expect.objectContaining({ credentials: "include" }),
58 | );
59 |
60 | await client.post({ path: "/test", body: {} });
61 | expect(global.fetch).toHaveBeenCalledWith(
62 | expect.any(URL),
63 | expect.objectContaining({ credentials: "include" }),
64 | );
65 | });
66 | });
67 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/src/feedback/ui/FeedbackDialog.css:
--------------------------------------------------------------------------------
```css
1 | .dialog {
2 | position: fixed;
3 | width: 210px;
4 | padding: 16px 22px 10px;
5 | font-size: var(--reflag-feedback-dialog-font-size, 1rem);
6 | font-family: var(
7 | --reflag-feedback-dialog-font-family,
8 | InterVariable,
9 | Inter,
10 | system-ui,
11 | Open Sans,
12 | sans-serif
13 | );
14 | color: var(--reflag-feedback-dialog-color, #1e1f24);
15 | border: 1px solid;
16 | border-color: var(--reflag-feedback-dialog-border, #d8d9df);
17 | border-radius: var(--reflag-feedback-dialog-border-radius, 6px);
18 | box-shadow: var(
19 | --reflag-feedback-dialog-box-shadow,
20 | 0 10px 15px -3px rgba(0, 0, 0, 0.1),
21 | 0 4px 6px -2px rgba(0, 0, 0, 0.05)
22 | );
23 | background-color: var(--reflag-feedback-dialog-background-color, #fff);
24 | z-index: 2147410000;
25 |
26 | &:not(.modal) {
27 | margin: unset;
28 | top: unset;
29 | right: unset;
30 | left: unset;
31 | bottom: unset;
32 | }
33 | }
34 |
35 | .arrow {
36 | background-color: var(--reflag-feedback-dialog-background-color, #fff);
37 | box-shadow: var(--reflag-feedback-dialog-border, #d8d9df) -1px -1px 1px 0px;
38 |
39 | &.bottom {
40 | box-shadow: var(--reflag-feedback-dialog-border, #d8d9df) -1px -1px 1px 0px;
41 | }
42 | &.top {
43 | box-shadow: var(--reflag-feedback-dialog-border, #d8d9df) 1px 1px 1px 0px;
44 | }
45 | &.left {
46 | box-shadow: var(--reflag-feedback-dialog-border, #d8d9df) 1px -1px 1px 0px;
47 | }
48 | &.right {
49 | box-shadow: var(--reflag-feedback-dialog-border, #d8d9df) -1px 1px 1px 0px;
50 | }
51 | }
52 |
53 | .close {
54 | position: absolute;
55 | top: 6px;
56 | right: 6px;
57 |
58 | width: 28px;
59 | height: 28px;
60 |
61 | padding: 0;
62 | margin: 0;
63 | background: none;
64 | border: none;
65 |
66 | cursor: pointer;
67 |
68 | display: flex;
69 | justify-content: center;
70 | align-items: center;
71 |
72 | color: var(--reflag-feedback-dialog-color, #1e1f24);
73 |
74 | svg {
75 | position: absolute;
76 | }
77 | }
78 |
79 | .plug {
80 | font-size: 0.75em;
81 | text-align: center;
82 | margin-top: 7px;
83 | width: 100%;
84 | }
85 |
86 | .plug a {
87 | opacity: 0.5;
88 | color: var(--reflag-feedback-dialog-color, #1e1f24);
89 | text-decoration: none;
90 |
91 | transition: opacity 200ms;
92 | }
93 |
94 | .plug a:hover {
95 | opacity: 0.7;
96 | }
97 |
```
--------------------------------------------------------------------------------
/docs.sh:
--------------------------------------------------------------------------------
```bash
1 | #!/bin/sh
2 |
3 | set -e
4 |
5 | typedoc --treatWarningsAsErrors
6 |
7 | # We need to fix the links in the generated markdown files.
8 | # Typedoc generates anchors for properties in tables which can collide with anchors for types.
9 | # For example we can have property `logger` and type `Logger` which will both have anchor `logger`.
10 | # typedoc-plugin-markdown will generate links trying to deduplicate the anchors by adding a number at the end.
11 | # Example: `globals.md#logger-1` and `globals.md#logger-2`.
12 | #
13 | # We don't need the anchors for properties in the markdown files
14 | # and they won't even work in Gitbook because they are generated as html <a> anchors.
15 | # We can fix this by removing the number at the end of the anchor.
16 | SEDCOMMAND='s/globals.md#(.*)-[0-9]+/globals.md#\1/g'
17 |
18 | # Find all markdown files including globals.md
19 | FILES=$(find dist/docs/@reflag -name "*.md")
20 |
21 | echo "Processing markdown files..."
22 | for file in $FILES
23 | do
24 | echo "Processing $file..."
25 |
26 | # Fix anchor links in globals.md files
27 | if [[ "$file" == *"globals.md" ]]; then
28 | sed -r "$SEDCOMMAND" "$file" > "$file.fixed"
29 | rm "$file"
30 | mv "$file.fixed" "$file"
31 | fi
32 |
33 | # Create a temporary file for processing
34 | tmp_file="${file}.tmp"
35 |
36 | # Process NOTE blocks - handle multi-line
37 | awk '
38 | BEGIN { in_block = 0; content = ""; }
39 | /^> \[!NOTE\]/ { in_block = 1; print "{% hint style=\"info\" %}"; next; }
40 | /^> \[!TIP\]/ { in_block = 1; print "{% hint style=\"success\" %}"; next; }
41 | /^> \[!IMPORTANT\]/ { in_block = 1; print "{% hint style=\"warning\" %}"; next; }
42 | /^> \[!WARNING\]/ { in_block = 1; print "{% hint style=\"warning\" %}"; next; }
43 | /^> \[!CAUTION\]/ { in_block = 1; print "{% hint style=\"danger\" %}"; next; }
44 | in_block && /^>/ {
45 | content = content substr($0, 3) "\n";
46 | next;
47 | }
48 | in_block && !/^>/ {
49 | printf "%s", content;
50 | print "{% endhint %}";
51 | in_block = 0;
52 | content = "";
53 | }
54 | !in_block { print; }
55 | ' "$file" > "$tmp_file"
56 |
57 | mv "$tmp_file" "$file"
58 | done
59 |
60 | echo "Processing complete!"
61 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/src/context.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Context is a set of key-value pairs.
3 | * This is used to determine if feature targeting matches and to track events.
4 | * Id should always be present so that it can be referenced to an existing company.
5 | */
6 | export interface CompanyContext {
7 | /**
8 | * Company id
9 | */
10 | id: string | number | undefined;
11 |
12 | /**
13 | * Company name
14 | */
15 | name?: string | undefined;
16 |
17 | /**
18 | * Other company attributes
19 | */
20 | [key: string]: string | number | undefined;
21 | }
22 |
23 | /**
24 | * Context is a set of key-value pairs.
25 | * This is used to determine if feature targeting matches and to track events.
26 | * Id should always be present so that it can be referenced to an existing user.
27 | */
28 | export interface UserContext {
29 | /**
30 | * User id
31 | */
32 | id: string | number | undefined;
33 |
34 | /**
35 | * User name
36 | */
37 | name?: string | undefined;
38 |
39 | /**
40 | * User email
41 | */
42 | email?: string | undefined;
43 |
44 | /**
45 | * Other user attributes
46 | */
47 | [key: string]: string | number | undefined;
48 | }
49 |
50 | /**
51 | * Context is a set of key-value pairs.
52 | * This is used to determine if feature targeting matches and to track events.
53 | */
54 | export interface ReflagContext {
55 | /**
56 | * Company related context. If you provide `id` Reflag will enrich the evaluation context with
57 | * company attributes on Reflag servers.
58 | */
59 | company?: CompanyContext;
60 |
61 | /**
62 | * User related context. If you provide `id` Reflag will enrich the evaluation context with
63 | * user attributes on Reflag servers.
64 | */
65 | user?: UserContext;
66 |
67 | /**
68 | * Context which is not related to a user or a company.
69 | */
70 | other?: Record<string, string | number | undefined>;
71 | }
72 |
73 | /**
74 | * @deprecated Use `ReflagContext` instead, this interface will be removed in the next major version
75 | * @internal
76 | */
77 | export interface ReflagDeprecatedContext extends ReflagContext {
78 | /**
79 | * Context which is not related to a user or a company.
80 | * @deprecated Use `other` instead, this property will be removed in the next major version
81 | */
82 | otherContext?: Record<string, string | number | undefined>;
83 | }
84 |
```
--------------------------------------------------------------------------------
/typedoc.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "$schema": "https://typedoc-plugin-markdown.org/schema.json",
3 |
4 | "plugin": [
5 | "typedoc-plugin-markdown",
6 | "typedoc-plugin-frontmatter",
7 | "typedoc-plugin-mdn-links"
8 | ],
9 |
10 | "entryPointStrategy": "packages",
11 | "entryPoints": [
12 | "packages/browser-sdk/",
13 | "packages/node-sdk/",
14 | "packages/react-sdk/",
15 | "packages/vue-sdk/"
16 | ],
17 | "packageOptions": {
18 | "entryPoints": ["src/index.ts"]
19 | },
20 | "projectDocuments": [
21 | "packages/browser-sdk/FEEDBACK.md",
22 | "packages/cli/README.md"
23 | ],
24 | "readme": "none",
25 | "useHTMLAnchors": false,
26 |
27 | "outputs": [
28 | {
29 | "name": "markdown",
30 | "path": "./dist/docs"
31 | }
32 | ],
33 | "outputFileStrategy": "modules",
34 | "membersWithOwnFile": [],
35 |
36 | "navigation": {
37 | "includeCategories": false,
38 | "includeGroups": false,
39 | "includeFolders": false,
40 | "excludeReferences": true
41 | },
42 |
43 | "disableSources": true,
44 | "categorizeByGroup": false,
45 | "groupReferencesByType": true,
46 | "commentStyle": "block",
47 | "useCodeBlocks": true,
48 | "expandObjects": true,
49 | "expandParameters": true,
50 | "typeDeclarationVisibility": "verbose",
51 |
52 | "indexFormat": "htmlTable",
53 | "parametersFormat": "htmlTable",
54 | "interfacePropertiesFormat": "htmlTable",
55 | "classPropertiesFormat": "htmlTable",
56 | "enumMembersFormat": "htmlTable",
57 | "propertiesFormat": "htmlTable",
58 | "propertyMembersFormat": "htmlTable",
59 | "typeDeclarationFormat": "htmlTable",
60 |
61 | "hideGroupHeadings": false,
62 | "hideBreadcrumbs": true,
63 | "hidePageTitle": false,
64 | "hidePageHeader": true,
65 |
66 | "tableColumnSettings": {
67 | "hideDefaults": false,
68 | "hideInherited": true,
69 | "hideModifiers": false,
70 | "hideOverrides": false,
71 | "hideSources": true,
72 | "hideValues": false,
73 | "leftAlignHeaders": false
74 | },
75 |
76 | "frontmatterGlobals": {
77 | "layout": {
78 | "visible": true
79 | },
80 | "title": {
81 | "visible": true
82 | },
83 | "description": {
84 | "visible": false
85 | },
86 | "tableOfContents": {
87 | "visible": true
88 | },
89 | "outline": {
90 | "visible": true
91 | },
92 | "pagination": {
93 | "visible": true
94 | }
95 | }
96 | }
97 |
```
--------------------------------------------------------------------------------
/packages/node-sdk/src/periodicallyUpdatingCache.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Cache, Logger } from "./types";
2 |
3 | /**
4 | * Create a cached function that updates the value asynchronously.
5 | *
6 | * The value is updated every `ttl` milliseconds.
7 | * If the value is older than `staleTtl` milliseconds, a warning is logged.
8 | *
9 | * @typeParam T - The type of the value.
10 | * @param ttl - The time-to-live in milliseconds.
11 | * @param staleTtl - The time-to-live after which a warning is logged.
12 | * @param logger - The logger to use.
13 | * @param fn - The function to call to get the value.
14 | * @returns The cache object.
15 | **/
16 | export default function periodicallyUpdatingCache<T>(
17 | ttl: number,
18 | staleTtl: number,
19 | logger: Logger | undefined,
20 | fn: () => Promise<T | undefined>,
21 | ): Cache<T> {
22 | let cachedValue: T | undefined;
23 | let lastUpdate: number | undefined;
24 | let timeoutId: NodeJS.Timeout | undefined;
25 | let refreshPromise: Promise<void> | undefined;
26 |
27 | const update = async () => {
28 | if (timeoutId) {
29 | clearTimeout(timeoutId);
30 | }
31 |
32 | try {
33 | const newValue = await fn();
34 | if (newValue === undefined) {
35 | return;
36 | }
37 | logger?.info("refreshed flag definitions");
38 |
39 | cachedValue = newValue;
40 |
41 | lastUpdate = Date.now();
42 |
43 | logger?.debug("updated cached value", cachedValue);
44 | } catch (e) {
45 | logger?.error("failed to update cached value", e);
46 | } finally {
47 | refreshPromise = undefined;
48 | timeoutId = setTimeout(update, ttl).unref();
49 | }
50 | };
51 |
52 | const get = () => {
53 | if (lastUpdate !== undefined) {
54 | const age = Date.now() - lastUpdate!;
55 | if (age > staleTtl) {
56 | logger?.warn("cached value is stale", { age, cachedValue });
57 | }
58 | }
59 |
60 | return cachedValue;
61 | };
62 |
63 | const refresh = async () => {
64 | if (!refreshPromise) {
65 | refreshPromise = update();
66 | }
67 | await refreshPromise;
68 | return get();
69 | };
70 |
71 | const waitRefresh = async () => {
72 | // no-op
73 | };
74 |
75 | const destroy = () => {
76 | if (timeoutId) {
77 | clearTimeout(timeoutId);
78 | timeoutId = undefined;
79 | }
80 | refreshPromise = undefined;
81 | };
82 |
83 | return {
84 | get,
85 | refresh,
86 | waitRefresh,
87 | destroy,
88 | };
89 | }
90 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/example/feedback/Feedback.jsx:
--------------------------------------------------------------------------------
```javascript
1 | export const FeedbackForm = () => {
2 | function handleSubmit(e) {
3 | e.preventDefault();
4 |
5 | const formData = Object.fromEntries(new FormData(e.target).entries());
6 |
7 | const feedbackPayload = {
8 | featureId: "EXAMPLE_FLAG",
9 | userId: "EXAMPLE_USER",
10 | companyId: "EXAMPLE_COMPANY",
11 | score: formData.score ? Number(formData.score) : null,
12 | comment: formData.comment ? formData.comment : null,
13 | };
14 |
15 | // Using the Reflag SDK
16 | new ReflagClient({
17 | publishableKey: "EXAMPLE_PUBLISHABLE_KEY",
18 | }).feedback(feedbackPayload);
19 |
20 | /*
21 | // Using the Reflag API
22 | fetch("https://front.reflag.com/feedback", {
23 | method: "POST",
24 | headers: {
25 | "Content-Type": "application/json",
26 | "Authorization": "Bearer EXAMPLE_PUBLISHABLE_KEY",
27 | },
28 | body: JSON.stringify(feedbackPayload),
29 | });
30 | */
31 | }
32 |
33 | return (
34 | <form action="#" onSubmit={handleSubmit}>
35 | <h2>How satisfied are you with our ExampleFlag?</h2>
36 |
37 | <fieldset>
38 | <legend>Satisfaction</legend>
39 |
40 | <div>
41 | <label>
42 | <input type="radio" name="score" value="1" />
43 | <span>Very unsatsified</span>
44 | </label>
45 | </div>
46 | <div>
47 | <label>
48 | <input type="radio" name="score" value="2" />
49 | <span>Unsatisfied</span>
50 | </label>
51 | </div>
52 | <div>
53 | <label>
54 | <input type="radio" name="score" value="3" />
55 | <span>Neutral</span>
56 | </label>
57 | </div>
58 | <div>
59 | <label>
60 | <input type="radio" name="score" value="4" />
61 | <span>Satisfied</span>
62 | </label>
63 | </div>
64 | <div>
65 | <label>
66 | <input type="radio" name="score" value="5" />
67 | <span>Very satsified</span>
68 | </label>
69 | </div>
70 | </fieldset>
71 |
72 | <div>
73 | <label>
74 | <div>Comment</div>
75 | <textarea name="comment" placeholder="Write a comment..."></textarea>
76 | </label>
77 | </div>
78 |
79 | <button type="submit">Send feedback</button>
80 | </form>
81 | );
82 | };
83 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/test/rateLimiter.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import {
2 | afterAll,
3 | beforeAll,
4 | beforeEach,
5 | describe,
6 | expect,
7 | it,
8 | vi,
9 | } from "vitest";
10 |
11 | import RateLimiter from "../src/rateLimiter";
12 |
13 | import { testLogger } from "./testLogger";
14 |
15 | describe("rateLimit", () => {
16 | beforeAll(() => {
17 | vi.useFakeTimers({ shouldAdvanceTime: true });
18 | });
19 |
20 | beforeEach(() => {
21 | vi.advanceTimersByTime(600000); // Advance time by 10 minutes
22 | });
23 |
24 | afterAll(() => {
25 | vi.useRealTimers();
26 | });
27 |
28 | it("should call the key generator", () => {
29 | const callback = vi.fn();
30 | const limiter = new RateLimiter(1, testLogger);
31 |
32 | for (let i = 0; i < 5; i++) {
33 | limiter.rateLimited(`${i}`, callback);
34 | }
35 |
36 | expect(callback).toHaveBeenCalledTimes(5);
37 | });
38 |
39 | it("should not call the callback when the limit is exceeded", () => {
40 | const callback = vi.fn();
41 | const limiter = new RateLimiter(5, testLogger);
42 |
43 | for (let i = 0; i < 10; i++) {
44 | limiter.rateLimited("key", callback);
45 | }
46 |
47 | expect(callback).toHaveBeenCalledTimes(5);
48 | });
49 |
50 | it("should reset the limit after a minute", () => {
51 | const callback = vi.fn();
52 | const limited = new RateLimiter(1, testLogger);
53 |
54 | for (let i = 0; i < 12; i++) {
55 | limited.rateLimited("key", () => callback(i));
56 | vi.advanceTimersByTime(6000); // Advance time by 6 seconds
57 | }
58 |
59 | expect(callback).toHaveBeenCalledTimes(2);
60 | expect(callback).toHaveBeenCalledWith(0);
61 | expect(callback).toHaveBeenCalledWith(11); // first one goes through after 1min
62 | });
63 |
64 | it("should measure events separately by key", () => {
65 | const callback = vi.fn();
66 | const limited = new RateLimiter(5, testLogger);
67 |
68 | for (let i = 0; i < 10; i++) {
69 | limited.rateLimited("key1", callback);
70 | limited.rateLimited("key2", callback);
71 | }
72 |
73 | expect(callback).toHaveBeenCalledTimes(10);
74 | });
75 |
76 | it("should return the value of the callback always", () => {
77 | const callback = vi.fn();
78 | const limited = new RateLimiter(5, testLogger);
79 |
80 | for (let i = 0; i < 5; i++) {
81 | callback.mockReturnValue(i);
82 | const res = limited.rateLimited("key", callback);
83 |
84 | expect(res).toBe(i);
85 | }
86 | });
87 | });
88 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/src/httpClient.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { API_BASE_URL, SDK_VERSION, SDK_VERSION_HEADER_NAME } from "./config";
2 |
3 | export interface HttpClientOptions {
4 | baseUrl?: string;
5 | sdkVersion?: string;
6 | credentials?: RequestCredentials;
7 | }
8 |
9 | export class HttpClient {
10 | private readonly baseUrl: string;
11 | private readonly sdkVersion: string;
12 |
13 | private readonly fetchOptions: RequestInit;
14 |
15 | constructor(
16 | public publishableKey: string,
17 | opts: HttpClientOptions = {},
18 | ) {
19 | this.baseUrl = opts.baseUrl ?? API_BASE_URL;
20 |
21 | // Ensure baseUrl ends with a trailing slash so subsequent
22 | // path concatenation works as expected
23 | if (!this.baseUrl.endsWith("/")) {
24 | this.baseUrl += "/";
25 | }
26 | this.sdkVersion = opts.sdkVersion ?? SDK_VERSION;
27 | this.fetchOptions = { credentials: opts.credentials };
28 | }
29 |
30 | getUrl(path: string): URL {
31 | // see tests for examples
32 | if (path.startsWith("/")) {
33 | path = path.slice(1);
34 | }
35 | return new URL(path, this.baseUrl);
36 | }
37 |
38 | async get({
39 | path,
40 | params,
41 | timeoutMs,
42 | }: {
43 | path: string;
44 | params?: URLSearchParams;
45 | timeoutMs?: number;
46 | }): ReturnType<typeof fetch> {
47 | if (!params) {
48 | params = new URLSearchParams();
49 | }
50 | params.set(SDK_VERSION_HEADER_NAME, this.sdkVersion);
51 | params.set("publishableKey", this.publishableKey);
52 |
53 | const url = this.getUrl(path);
54 | url.search = params.toString();
55 |
56 | if (timeoutMs === undefined) {
57 | return fetch(url, this.fetchOptions);
58 | }
59 |
60 | const controller = new AbortController();
61 | const id = setTimeout(() => controller.abort(), timeoutMs);
62 |
63 | const res = await fetch(url, {
64 | ...this.fetchOptions,
65 | signal: controller.signal,
66 | });
67 | clearTimeout(id);
68 |
69 | return res;
70 | }
71 |
72 | async post({
73 | path,
74 | body,
75 | }: {
76 | host?: string;
77 | path: string;
78 | body: any;
79 | }): ReturnType<typeof fetch> {
80 | return fetch(this.getUrl(path), {
81 | ...this.fetchOptions,
82 | method: "POST",
83 | headers: {
84 | "Content-Type": "application/json",
85 | [SDK_VERSION_HEADER_NAME]: this.sdkVersion,
86 | Authorization: `Bearer ${this.publishableKey}`,
87 | },
88 | body: JSON.stringify(body),
89 | });
90 | }
91 | }
92 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/src/ui/Dialog.css:
--------------------------------------------------------------------------------
```css
1 | /* Animations */
2 |
3 | @keyframes scale {
4 | from {
5 | transform: scale(0.95);
6 | }
7 | to {
8 | transform: scale(1);
9 | }
10 | }
11 |
12 | @keyframes floatUp {
13 | from {
14 | transform: translateY(15%);
15 | }
16 | to {
17 | transform: translateY(0%);
18 | }
19 | }
20 |
21 | @keyframes floatDown {
22 | from {
23 | transform: translateY(-15%);
24 | }
25 | to {
26 | transform: translateY(0%);
27 | }
28 | }
29 |
30 | @keyframes fade {
31 | from {
32 | opacity: 0;
33 | }
34 | to {
35 | opacity: 1;
36 | }
37 | }
38 |
39 | /* Modal */
40 |
41 | .dialog.modal {
42 | margin: auto;
43 | margin-top: 4rem;
44 |
45 | &[open] {
46 | animation: /* easeOutQuint */
47 | scale 100ms cubic-bezier(0.22, 1, 0.36, 1),
48 | fade 100ms cubic-bezier(0.22, 1, 0.36, 1);
49 |
50 | &::backdrop {
51 | animation: fade 150ms cubic-bezier(0.22, 1, 0.36, 1);
52 | }
53 | }
54 | }
55 |
56 | /* Anchored */
57 |
58 | .dialog.anchored {
59 | position: absolute;
60 | margin: 0;
61 |
62 | &[open] {
63 | animation: /* easeOutQuint */
64 | scale 100ms cubic-bezier(0.22, 1, 0.36, 1),
65 | fade 100ms cubic-bezier(0.22, 1, 0.36, 1);
66 | }
67 | &.top-start {
68 | transform-origin: bottom left;
69 | }
70 | &.top-end {
71 | transform-origin: bottom right;
72 | }
73 | &.right-start {
74 | transform-origin: left top;
75 | }
76 | &.right-end {
77 | transform-origin: left bottom;
78 | }
79 | &.bottom-start {
80 | transform-origin: top left;
81 | }
82 | &.bottom-end {
83 | transform-origin: top right;
84 | }
85 | &.left-start {
86 | transform-origin: right top;
87 | }
88 | &.left-end {
89 | transform-origin: right bottom;
90 | }
91 | }
92 |
93 | /* Unanchored */
94 |
95 | .dialog[open].unanchored {
96 | &.unanchored-bottom-left,
97 | &.unanchored-bottom-right {
98 | animation: /* easeOutQuint */
99 | floatUp 300ms cubic-bezier(0.22, 1, 0.36, 1),
100 | fade 300ms cubic-bezier(0.22, 1, 0.36, 1);
101 | }
102 |
103 | &.unanchored-top-left,
104 | &.unanchored-top-right {
105 | animation: /* easeOutQuint */
106 | floatDown 300ms cubic-bezier(0.22, 1, 0.36, 1),
107 | fade 300ms cubic-bezier(0.22, 1, 0.36, 1);
108 | }
109 | }
110 |
111 | .dialog .arrow {
112 | position: absolute;
113 | width: 8px;
114 | height: 8px;
115 | transform: rotate(45deg);
116 | }
117 |
118 | .dialog-header {
119 | display: flex;
120 | align-items: center;
121 | justify-content: space-between;
122 | gap: 1px;
123 | border-bottom: 1px solid var(--border-color);
124 | padding: 3px 12px;
125 | }
126 |
127 | .dialog-content {
128 | position: relative;
129 | padding: 7px 12px;
130 | }
131 |
```
--------------------------------------------------------------------------------
/packages/cli/services/flags.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { z } from "zod";
2 |
3 | import { authRequest } from "../utils/auth.js";
4 | import { booleanish, EnvironmentQuerySchema } from "../utils/schemas.js";
5 | import { PaginatedResponse } from "../utils/types.js";
6 |
7 | export type Stage = {
8 | id: string;
9 | name: string;
10 | order: number;
11 | };
12 |
13 | export type RemoteConfigVariant = {
14 | key?: string;
15 | payload?: any;
16 | };
17 |
18 | export type RemoteConfig = {
19 | variants: [
20 | {
21 | variant: RemoteConfigVariant;
22 | },
23 | ];
24 | };
25 |
26 | export type FlagName = {
27 | id: string;
28 | name: string;
29 | key: string;
30 | };
31 |
32 | export type Flag = FlagName & {
33 | description: string | null;
34 | remoteConfigs: RemoteConfig[];
35 | stage: Stage | null;
36 | };
37 |
38 | export type FlagsResponse = PaginatedResponse<Flag>;
39 |
40 | export const FlagsQuerySchema = EnvironmentQuerySchema.extend({
41 | sortBy: z.string().default("key").describe("Field to sort features by"),
42 | sortOrder: z
43 | .enum(["asc", "desc"])
44 | .default("asc")
45 | .describe("Sort direction (ascending or descending)"),
46 | includeRemoteConfigs: booleanish
47 | .default(false)
48 | .describe("Include remote configuration data"),
49 | }).strict();
50 |
51 | export const FlagCreateSchema = z
52 | .object({
53 | name: z
54 | .string()
55 | .min(1, "Flag name is required")
56 | .describe("Name of the flag"),
57 | key: z
58 | .string()
59 | .min(1, "Flag key is required")
60 | .describe("Unique identifier key for the flag"),
61 | description: z
62 | .string()
63 | .optional()
64 | .describe("Optional description of the flag"),
65 | })
66 | .strict();
67 |
68 | export type FlagsQuery = z.input<typeof FlagsQuerySchema>;
69 | export type FlagCreate = z.input<typeof FlagCreateSchema>;
70 |
71 | export async function listFlags(appId: string, query: FlagsQuery) {
72 | return authRequest<FlagsResponse>(`/apps/${appId}/features`, {
73 | params: FlagsQuerySchema.parse(query),
74 | });
75 | }
76 |
77 | type CreateFlagResponse = {
78 | feature: FlagName & {
79 | description: string | null;
80 | };
81 | };
82 |
83 | export async function createFlag(appId: string, featureData: FlagCreate) {
84 | return authRequest<CreateFlagResponse>(`/apps/${appId}/features`, {
85 | method: "POST",
86 | headers: {
87 | "Content-Type": "application/json",
88 | },
89 | body: JSON.stringify({
90 | source: "event",
91 | ...FlagCreateSchema.parse(featureData),
92 | }),
93 | }).then(({ feature }) => feature);
94 | }
95 |
```
--------------------------------------------------------------------------------
/packages/cli/services/mcp.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { resolvePath } from "../utils/file.js";
2 |
3 | export const SupportedEditors = [
4 | "cursor",
5 | "vscode",
6 | "claude",
7 | "windsurf",
8 | ] as const;
9 | export type SupportedEditor = (typeof SupportedEditors)[number];
10 |
11 | type ConfigPaths = {
12 | name: string;
13 | global:
14 | | {
15 | mac: string;
16 | windows: string;
17 | linux?: string;
18 | }
19 | | string;
20 | local?: string;
21 | };
22 |
23 | export const ConfigPaths: Record<SupportedEditor, ConfigPaths> = {
24 | cursor: {
25 | name: "Cursor",
26 | global: "~/.cursor/mcp.json",
27 | local: ".cursor/mcp.json",
28 | },
29 | vscode: {
30 | name: "Visual Studio Code",
31 | global: {
32 | mac: "~/Library/Application Support/Code/User/settings.json",
33 | linux: "~/.config/Code/User/settings.json",
34 | windows: "@/Code/User/settings.json",
35 | },
36 | local: ".vscode/mcp.json",
37 | },
38 | claude: {
39 | name: "Claude Desktop",
40 | global: {
41 | mac: "~/Library/Application Support/Claude/claude_desktop_config.json",
42 | windows: "@/Claude/claude_desktop_config.json",
43 | },
44 | },
45 | windsurf: {
46 | name: "Windsurf",
47 | global: "~/.codeium/windsurf/mcp_config.json",
48 | },
49 | };
50 |
51 | export function resolveConfigPath(editor: SupportedEditor, local = false) {
52 | const editorConfig = ConfigPaths[editor];
53 | const paths = local ? editorConfig.local : editorConfig.global;
54 |
55 | if (!paths) return undefined;
56 |
57 | if (typeof paths === "string") {
58 | return resolvePath(paths);
59 | }
60 |
61 | // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
62 | switch (process.platform) {
63 | case "darwin":
64 | return resolvePath(paths.mac);
65 | case "win32":
66 | return resolvePath(paths.windows);
67 | case "linux":
68 | return paths.linux ? resolvePath(paths.linux) : undefined;
69 | default:
70 | return undefined;
71 | }
72 | }
73 |
74 | export function getServersConfig(
75 | editorConfig: any,
76 | selectedEditor: SupportedEditor,
77 | configPathType: "global" | "local",
78 | ) {
79 | if (selectedEditor === "vscode") {
80 | if (configPathType === "global") {
81 | editorConfig.mcp = editorConfig.mcp || {};
82 | editorConfig.mcp.servers = editorConfig.mcp.servers || {};
83 | return editorConfig.mcp.servers;
84 | } else {
85 | editorConfig.servers = editorConfig.servers || {};
86 | return editorConfig.servers;
87 | }
88 | }
89 | editorConfig.mcpServers = editorConfig.mcpServers || {};
90 | return editorConfig.mcpServers;
91 | }
92 |
```
--------------------------------------------------------------------------------
/packages/vue-sdk/dev/plain/App.vue:
--------------------------------------------------------------------------------
```vue
1 | <script setup lang="ts">
2 | import { computed, ref } from "vue";
3 |
4 | import { ReflagBootstrappedProvider, ReflagProvider } from "../../src";
5 |
6 | import Events from "./components/Events.vue";
7 | import FlagsList from "./components/FlagsList.vue";
8 | import MissingKeyMessage from "./components/MissingKeyMessage.vue";
9 | import RequestFeedback from "./components/RequestFeedback.vue";
10 | import Section from "./components/Section.vue";
11 | import StartHuddlesButton from "./components/StartHuddlesButton.vue";
12 | import Track from "./components/Track.vue";
13 |
14 | // Initial context
15 | const initialUser = { id: "demo-user", email: "[email protected]" };
16 | const initialCompany = { id: "demo-company", name: "Demo Company" };
17 | const initialOther = { test: "test" };
18 |
19 | const context = ref({
20 | user: initialUser,
21 | company: initialCompany,
22 | other: initialOther,
23 | });
24 |
25 | const publishableKey = import.meta.env.VITE_PUBLISHABLE_KEY || "";
26 | const apiBaseUrl = import.meta.env.VITE_REFLAG_API_BASE_URL;
27 |
28 | // Check for bootstrapped query parameter
29 | const isBootstrapped = computed(() => {
30 | const urlParams = new URLSearchParams(window.location.search);
31 | return urlParams.get("bootstrapped") !== null;
32 | });
33 | </script>
34 |
35 | <template>
36 | <div v-if="!publishableKey">
37 | <MissingKeyMessage />
38 | </div>
39 |
40 | <!-- Bootstrapped Provider -->
41 | <ReflagBootstrappedProvider
42 | v-else-if="isBootstrapped"
43 | :publishable-key="publishableKey"
44 | :flags="{
45 | context,
46 | flags: {
47 | huddles: {
48 | key: 'huddles',
49 | isEnabled: true,
50 | },
51 | },
52 | }"
53 | :api-base-url="apiBaseUrl"
54 | >
55 | <template #loading>......loading......</template>
56 | <h1>Vue SDK (Bootstrapped)</h1>
57 | <StartHuddlesButton />
58 | <Track />
59 | <RequestFeedback />
60 |
61 | <Section title="Set User ID">
62 | <input v-model="context.user.id" />
63 | </Section>
64 | <Events />
65 | <FlagsList />
66 | </ReflagBootstrappedProvider>
67 |
68 | <!-- Regular Provider -->
69 | <ReflagProvider
70 | v-else
71 | :publishable-key="publishableKey"
72 | :context="context"
73 | :api-base-url="apiBaseUrl"
74 | >
75 | <template #loading>......loading......</template>
76 | <h1>Vue SDK</h1>
77 | <StartHuddlesButton />
78 | <Track />
79 | <RequestFeedback />
80 |
81 | <Section title="Set User ID">
82 | <input v-model="context.user.id" />
83 | </Section>
84 | <Events />
85 | <FlagsList />
86 | </ReflagProvider>
87 | </template>
88 |
```
--------------------------------------------------------------------------------
/packages/cli/utils/errors.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { ExitPromptError } from "@inquirer/core";
2 | import { ErrorObject } from "ajv";
3 | import chalk from "chalk";
4 |
5 | export class MissingAppIdError extends Error {
6 | constructor() {
7 | super(
8 | "App ID is required. Please provide it with --appId or in the config file. Use `reflag apps list` to see available apps.",
9 | );
10 | this.name = "MissingAppIdError";
11 | }
12 | }
13 |
14 | export class MissingEnvIdError extends Error {
15 | constructor() {
16 | super("Environment ID is required.");
17 | this.name = "MissingEnvIdError";
18 | }
19 | }
20 |
21 | export class ConfigValidationError extends Error {
22 | constructor(errors?: ErrorObject[] | null) {
23 | const messages = errors
24 | ?.map((e) => {
25 | const path = e.instancePath || "config";
26 | const value = e.params?.allowedValues
27 | ? `: ${e.params.allowedValues.join(", ")}`
28 | : "";
29 | return `${path}: ${e.message}${value}`;
30 | })
31 | .join("\n");
32 | super(messages);
33 | this.name = "ConfigValidationError";
34 | }
35 | }
36 |
37 | type ResponseErrorData = {
38 | error?: {
39 | message?: string;
40 | code?: string;
41 | };
42 | validationErrors?: { path: string[]; message: string }[];
43 | };
44 |
45 | export class ResponseError extends Error {
46 | public readonly data: ResponseErrorData;
47 |
48 | constructor(response: ResponseErrorData) {
49 | super(response.error?.message ?? response.error?.code);
50 | this.data = response;
51 | this.name = "ResponseError";
52 | }
53 | }
54 |
55 | export function handleError(error: unknown, tag: string): never {
56 | tag = chalk.bold(`\n[${tag}] error:`);
57 |
58 | if (error instanceof ExitPromptError) {
59 | process.exit(0);
60 | } else if (error instanceof ResponseError) {
61 | console.error(
62 | chalk.red(tag, error.data.error?.message ?? error.data.error?.code),
63 | );
64 |
65 | if (error.data.validationErrors) {
66 | console.table(
67 | error.data.validationErrors.map(
68 | ({ path, message }: { path: string[]; message: string }) => ({
69 | path: path.join("."),
70 | error: message,
71 | }),
72 | ),
73 | );
74 | }
75 | } else if (error instanceof Error) {
76 | console.error(chalk.red(tag, error.message));
77 |
78 | if (error.cause) {
79 | console.error(error.cause);
80 | }
81 | } else if (typeof error === "string") {
82 | console.error(chalk.red(tag, error));
83 | } else {
84 | console.error(chalk.red(tag ?? "An unknown error occurred:", error));
85 | }
86 |
87 | process.exit(1);
88 | }
89 |
```
--------------------------------------------------------------------------------
/packages/cli/utils/options.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Argument, Option } from "commander";
2 |
3 | import { CONFIG_FILE_NAME } from "./constants.js";
4 |
5 | // Define supported editors directly here or import from a central place if needed elsewhere
6 | const SUPPORTED_EDITORS = ["cursor", "vscode"] as const; // Add more later: "claude", "cline", "windsurf"
7 |
8 | export const debugOption = new Option("--debug", "Enable debug mode.");
9 |
10 | export const baseUrlOption = new Option(
11 | "--base-url [url]",
12 | `Reflag service URL (useful if behind a proxy). Falls back to baseUrl value in ${CONFIG_FILE_NAME}.`,
13 | );
14 |
15 | export const apiUrlOption = new Option(
16 | "--api-url [url]",
17 | `Reflag API URL (useful if behind a proxy). Falls back to apiUrl value in ${CONFIG_FILE_NAME} or baseUrl with /api appended.`,
18 | );
19 |
20 | export const apiKeyOption = new Option(
21 | "--api-key [key]",
22 | `Reflag API key. Can be used in CI/CD pipelines where logging in is not possible.`,
23 | );
24 |
25 | export const appIdOption = new Option(
26 | "-a, --appId [appId]",
27 | `Reflag App ID. Falls back to appId value in ${CONFIG_FILE_NAME}.`,
28 | );
29 |
30 | export const overwriteOption = new Option(
31 | "--overwrite",
32 | "Force initialization and overwrite existing configuration.",
33 | );
34 |
35 | export const typesOutOption = new Option(
36 | "-o, --out [path]",
37 | `Single output path for generated flag types. Falls back to typesOutput value in ${CONFIG_FILE_NAME}.`,
38 | );
39 |
40 | export const typesFormatOption = new Option(
41 | "-f, --format [format]",
42 | "Single output format for generated flag types",
43 | ).choices(["react", "node"]);
44 |
45 | export const flagNameArgument = new Argument(
46 | "[name]",
47 | "Flag's name. If not provided, you'll be prompted to enter one.",
48 | );
49 |
50 | export const flagKeyOption = new Option(
51 | "-k, --key [flag key]",
52 | "Flag key. If not provided, a key is generated from the flag's name.",
53 | );
54 |
55 | export const editorOption = new Option(
56 | "-e, --editor [editor]",
57 | "Specify the editor to configure for MCP.",
58 | ).choices(SUPPORTED_EDITORS);
59 |
60 | export const configScopeOption = new Option(
61 | "-s, --scope [scope]",
62 | "Specify whether to use local or global configuration.",
63 | ).choices(["local", "global"]);
64 |
65 | export const rulesFormatOption = new Option(
66 | "-f, --format [format]",
67 | "Format to copy rules in",
68 | )
69 | .choices(["cursor", "copilot"])
70 | .default("cursor");
71 |
72 | export const yesOption = new Option(
73 | "-y, --yes",
74 | "Skip confirmation prompts and overwrite existing files without asking.",
75 | );
76 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/index.html:
--------------------------------------------------------------------------------
```html
1 | <!doctype html>
2 | <html lang="en">
3 | <head>
4 | <meta charset="UTF-8" />
5 | <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6 | <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7 | <meta name="color-scheme" content="light dark" />
8 | <title>Reflag Browser SDK</title>
9 | </head>
10 | <body style="background-color: white">
11 | <div id="app"></div>
12 | <span id="loading">Loading...</span>
13 |
14 | <script>
15 | const urlParams = new URLSearchParams(window.location.search);
16 | const publishableKey = urlParams.get("publishableKey");
17 | const flagKey = urlParams.get("flagKey") ?? "huddles";
18 | const isBootstrapped = urlParams.get("bootstrapped") === "true";
19 | </script>
20 | <style>
21 | body {
22 | font-family: sans-serif;
23 | }
24 | #start-huddle {
25 | border: 1px solid black;
26 | padding: 10px;
27 | }
28 | </style>
29 | <div id="start-huddle" style="display: none">
30 | <button
31 | onClick="reflag.requestFeedback({flagKey, position: {type: 'POPOVER', anchor: event.currentTarget}})"
32 | >
33 | Give feedback!
34 | </button>
35 | <button onClick="reflag.track(flagKey)">Start huddle</button>
36 | </div>
37 |
38 | <script type="module">
39 | import { ReflagClient } from "./src/index.ts";
40 |
41 | window.reflag = new ReflagClient({
42 | publishableKey,
43 | user: { id: "42" },
44 | company: { id: "1" },
45 | toolbar: {
46 | show: true,
47 | position: {
48 | placement: "bottom-right",
49 | },
50 | },
51 | bootstrappedFlags: isBootstrapped
52 | ? {
53 | [flagKey]: {
54 | key: flagKey,
55 | isEnabled: true,
56 | },
57 | }
58 | : undefined,
59 | });
60 |
61 | function setVisibility(isVisible) {
62 | const startHuddleElem = document.getElementById("start-huddle");
63 | if (startHuddleElem)
64 | startHuddleElem.style.display = isVisible ? "block" : "none";
65 | }
66 |
67 | reflag.initialize().then(() => {
68 | console.log("Reflag initialized");
69 | document.getElementById("loading").style.display = "none";
70 | if (isBootstrapped) {
71 | const flag = reflag.getFlag(flagKey);
72 | setVisibility(flag.isEnabled);
73 | }
74 | });
75 |
76 | reflag.on("check", (check) =>
77 | console.log(`Check event for ${check.key}`),
78 | );
79 |
80 | reflag.on("flagsUpdated", (flags) => {
81 | console.log("Flags updated");
82 | const flag = reflag.getFlag(flagKey);
83 | setVisibility(flag.isEnabled);
84 | });
85 | </script>
86 | </body>
87 | </html>
88 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/example/feedback/feedback.html:
--------------------------------------------------------------------------------
```html
1 | <!doctype html>
2 | <html lang="en">
3 | <head>
4 | <meta charset="UTF-8" />
5 | <title>Reflag feedback collection</title>
6 | <script src="https://cdn.jsdelivr.net/npm/@reflag/browser-sdk@latest"></script>
7 | <style>
8 | fieldset label {
9 | display: flex;
10 | align-items: center;
11 | padding: 5px;
12 | gap: 5px;
13 | }
14 |
15 | label input {
16 | margin: 0;
17 | }
18 | </style>
19 | </head>
20 | <body>
21 | <script>
22 | function handleSubmit(e) {
23 | e.preventDefault();
24 |
25 | const formData = Object.fromEntries(new FormData(e.target).entries());
26 |
27 | const feedbackPayload = {
28 | featureId: "EXAMPLE_FLAG",
29 | userId: "EXAMPLE_USER",
30 | companyId: "EXAMPLE_COMPANY",
31 | score: formData.score ? Number(formData.score) : null,
32 | comment: formData.comment ? formData.comment : null,
33 | };
34 |
35 | // Using the Reflag SDK
36 | new ReflagClient({
37 | publishableKey: "EXAMPLE_PUBLISHABLE_KEY",
38 | }).feedback(feedbackPayload);
39 |
40 | /*
41 | // Using the Reflag API
42 | fetch("https://front.reflag.com/feedback", {
43 | method: "POST",
44 | headers: {
45 | "Content-Type": "application/json",
46 | "Authorization": "Bearer EXAMPLE_PUBLISHABLE_KEY",
47 | },
48 | body: JSON.stringify(feedbackPayload),
49 | });
50 | */
51 | }
52 | </script>
53 |
54 | <form action="#" onsubmit="handleSubmit(event)">
55 | <h2>How satisfied are you with our ExampleFlag?</h2>
56 |
57 | <fieldset>
58 | <legend>Satisfaction</legend>
59 |
60 | <div>
61 | <label>
62 | <input type="radio" name="score" value="1" />
63 | <span>Very unsatsified</span>
64 | </label>
65 | </div>
66 | <div>
67 | <label>
68 | <input type="radio" name="score" value="2" />
69 | <span>Unsatisfied</span>
70 | </label>
71 | </div>
72 | <div>
73 | <label>
74 | <input type="radio" name="score" value="3" />
75 | <span>Neutral</span>
76 | </label>
77 | </div>
78 | <div>
79 | <label>
80 | <input type="radio" name="score" value="4" />
81 | <span>Satisfied</span>
82 | </label>
83 | </div>
84 | <div>
85 | <label>
86 | <input type="radio" name="score" value="5" />
87 | <span>Very satsified</span>
88 | </label>
89 | </div>
90 | </fieldset>
91 |
92 | <div>
93 | <label>
94 | <div>Comment</div>
95 | <textarea name="comment" placeholder="Write a comment..."></textarea>
96 | </label>
97 | </div>
98 |
99 | <button type="submit">Send feedback</button>
100 | </form>
101 | </body>
102 | </html>
103 |
```
--------------------------------------------------------------------------------
/packages/node-sdk/src/batch-buffer.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { BATCH_INTERVAL_MS, BATCH_MAX_SIZE } from "./config";
2 | import { BatchBufferOptions, Logger } from "./types";
3 | import { isObject, ok } from "./utils";
4 |
5 | /**
6 | * A buffer that accumulates items and flushes them in batches.
7 | * @typeparam T - The type of items to buffer.
8 | */
9 | export default class BatchBuffer<T> {
10 | private buffer: T[] = [];
11 | private flushHandler: (items: T[]) => Promise<void>;
12 | private logger?: Logger;
13 | private maxSize: number;
14 | private intervalMs: number;
15 | private timer: NodeJS.Timeout | null = null;
16 |
17 | /**
18 | * Creates a new `BatchBuffer` instance.
19 | * @param options - The options to configure the buffer.
20 | * @throws If the options are invalid.
21 | */
22 | constructor(options: BatchBufferOptions<T>) {
23 | ok(isObject(options), "options must be an object");
24 | ok(
25 | typeof options.flushHandler === "function",
26 | "flushHandler must be a function",
27 | );
28 | ok(isObject(options.logger) || !options.logger, "logger must be an object");
29 | ok(
30 | (typeof options.maxSize === "number" && options.maxSize > 0) ||
31 | typeof options.maxSize !== "number",
32 | "maxSize must be greater than 0",
33 | );
34 | ok(
35 | (typeof options.intervalMs === "number" && options.intervalMs >= 0) ||
36 | typeof options.intervalMs !== "number",
37 | "intervalMs must be greater than or equal to 0",
38 | );
39 |
40 | this.flushHandler = options.flushHandler;
41 | this.logger = options.logger;
42 | this.maxSize = options.maxSize ?? BATCH_MAX_SIZE;
43 | this.intervalMs = options.intervalMs ?? BATCH_INTERVAL_MS;
44 | }
45 |
46 | /**
47 | * Adds an item to the buffer.
48 | *
49 | * @param item - The item to add.
50 | */
51 | public async add(item: T) {
52 | this.buffer.push(item);
53 |
54 | if (this.buffer.length >= this.maxSize) {
55 | await this.flush();
56 | } else if (!this.timer && this.intervalMs > 0) {
57 | this.timer = setTimeout(() => this.flush(), this.intervalMs).unref();
58 | }
59 | }
60 |
61 | public async flush(): Promise<void> {
62 | if (this.timer) {
63 | clearTimeout(this.timer);
64 | this.timer = null;
65 | }
66 |
67 | if (this.buffer.length === 0) {
68 | this.logger?.debug("buffer is empty. nothing to flush");
69 | return;
70 | }
71 |
72 | const flushingBuffer = this.buffer;
73 | this.buffer = [];
74 |
75 | try {
76 | await this.flushHandler(flushingBuffer);
77 |
78 | this.logger?.info("flushed buffered items", {
79 | count: flushingBuffer.length,
80 | });
81 | } catch (error) {
82 | this.logger?.error("flush of buffered items failed; discarding items", {
83 | error,
84 | count: flushingBuffer.length,
85 | });
86 | }
87 | }
88 |
89 | /**
90 | * Destroys the buffer, clearing any pending timer and discarding buffered items.
91 | */
92 | public destroy(): void {
93 | if (this.timer) {
94 | clearTimeout(this.timer);
95 | this.timer = null;
96 | }
97 | this.buffer = [];
98 | }
99 | }
100 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/test/init.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { DefaultBodyType, http, StrictRequest } from "msw";
2 | import { beforeEach, describe, expect, test, vi, vitest } from "vitest";
3 |
4 | import { ReflagClient } from "../src";
5 | import { HttpClient } from "../src/httpClient";
6 |
7 | import { getFlags } from "./mocks/handlers";
8 | import { server } from "./mocks/server";
9 |
10 | const KEY = "123";
11 |
12 | const logger = {
13 | debug: vi.fn(),
14 | info: vi.fn(),
15 | warn: vi.fn(),
16 | error: vi.fn(),
17 | };
18 |
19 | beforeEach(() => {
20 | vi.clearAllMocks();
21 | });
22 |
23 | describe("init", () => {
24 | test("will accept setup with key and debug logger", async () => {
25 | const reflagInstance = new ReflagClient({
26 | publishableKey: KEY,
27 | user: { id: 42 },
28 | company: { id: 42 },
29 | logger,
30 | });
31 | const spyInit = vi.spyOn(reflagInstance, "initialize");
32 |
33 | await reflagInstance.initialize();
34 | expect(spyInit).toHaveBeenCalled();
35 | expect(logger.debug).toHaveBeenCalled();
36 | });
37 |
38 | test("will accept setup with custom host", async () => {
39 | let usedSpecialHost = false;
40 |
41 | server.use(
42 | http.get(
43 | "https://example.com/features/evaluated",
44 | ({ request }: { request: StrictRequest<DefaultBodyType> }) => {
45 | usedSpecialHost = true;
46 | return getFlags({ request });
47 | },
48 | ),
49 | );
50 | const reflagInstance = new ReflagClient({
51 | publishableKey: KEY,
52 | user: { id: "foo" },
53 | apiBaseUrl: "https://example.com",
54 | });
55 | await reflagInstance.initialize();
56 |
57 | expect(usedSpecialHost).toBe(true);
58 | });
59 |
60 | test("automatically does user/company tracking", async () => {
61 | const user = vitest.spyOn(ReflagClient.prototype as any, "user");
62 | const company = vitest.spyOn(ReflagClient.prototype as any, "company");
63 |
64 | const reflagInstance = new ReflagClient({
65 | publishableKey: KEY,
66 | user: { id: "foo" },
67 | company: { id: "bar" },
68 | });
69 | await reflagInstance.initialize();
70 |
71 | expect(user).toHaveBeenCalled();
72 | expect(company).toHaveBeenCalled();
73 | });
74 |
75 | test("can disable tracking and auto. feedback surveys", async () => {
76 | const post = vitest.spyOn(HttpClient.prototype as any, "post");
77 |
78 | const reflagInstance = new ReflagClient({
79 | publishableKey: KEY,
80 | user: { id: "foo" },
81 | apiBaseUrl: "https://example.com",
82 | enableTracking: false,
83 | feedback: {
84 | enableAutoFeedback: false,
85 | },
86 | });
87 | await reflagInstance.initialize();
88 | await reflagInstance.track("test");
89 |
90 | expect(post).not.toHaveBeenCalled();
91 | });
92 |
93 | test("passes credentials correctly to httpClient", async () => {
94 | const credentials = "include";
95 | const reflagInstance = new ReflagClient({
96 | publishableKey: KEY,
97 | user: { id: "foo" },
98 | credentials,
99 | });
100 |
101 | await reflagInstance.initialize();
102 |
103 | expect(reflagInstance["httpClient"]["fetchOptions"].credentials).toBe(
104 | credentials,
105 | );
106 | });
107 | });
108 |
```
--------------------------------------------------------------------------------
/packages/node-sdk/src/fetch-http-client.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { API_TIMEOUT_MS } from "./config";
2 | import { HttpClient } from "./types";
3 | import { ok } from "./utils";
4 |
5 | /**
6 | * The default HTTP client implementation.
7 | *
8 | * @remarks
9 | * This implementation uses the `fetch` API to send HTTP requests.
10 | **/
11 | const fetchClient: HttpClient = {
12 | post: async <TBody, TResponse>(
13 | url: string,
14 | headers: Record<string, string>,
15 | body: TBody,
16 | timeoutMs: number = API_TIMEOUT_MS,
17 | ) => {
18 | ok(typeof url === "string" && url.length > 0, "URL must be a string");
19 | ok(typeof headers === "object", "Headers must be an object");
20 |
21 | const response = await fetch(url, {
22 | method: "post",
23 | headers,
24 | body: JSON.stringify(body),
25 | signal: AbortSignal.timeout(timeoutMs),
26 | });
27 |
28 | const json = await response.json();
29 | return {
30 | ok: response.ok,
31 | status: response.status,
32 | body: json as TResponse,
33 | };
34 | },
35 |
36 | get: async <TResponse>(
37 | url: string,
38 | headers: Record<string, string>,
39 | timeoutMs: number = API_TIMEOUT_MS,
40 | ) => {
41 | ok(typeof url === "string" && url.length > 0, "URL must be a string");
42 | ok(typeof headers === "object", "Headers must be an object");
43 |
44 | const response = await fetch(url, {
45 | method: "get",
46 | headers,
47 | signal: AbortSignal.timeout(timeoutMs),
48 | // We must use no-cache to avoid services such as Next.js from caching the response indefinitely.
49 | // We also can't use no-store because of Next.js error withRetry https://github.com/vercel/next.js/discussions/54036.
50 | // We also have local caching in the SDKs, so we don't need to cache the response.
51 | cache: "no-cache",
52 | });
53 |
54 | const json = await response.json();
55 | return {
56 | ok: response.ok,
57 | status: response.status,
58 | body: json as TResponse,
59 | };
60 | },
61 | };
62 |
63 | /**
64 | * Implements exponential backoff retry logic for async functions.
65 | *
66 | * @param fn - The async function to retry.
67 | * @param maxRetries - Maximum number of retry attempts.
68 | * @param baseDelay - Base delay in milliseconds before retrying.
69 | * @param maxDelay - Maximum delay in milliseconds.
70 | * @returns The result of the function call or throws an error if all retries fail.
71 | */
72 | export async function withRetry<T>(
73 | fn: () => Promise<T>,
74 | onFailedTry: (error: unknown) => void,
75 | maxRetries: number,
76 | baseDelay: number,
77 | maxDelay: number,
78 | ): Promise<T> {
79 | let lastError: unknown;
80 |
81 | for (let attempt = 0; attempt <= maxRetries; attempt++) {
82 | try {
83 | return await fn();
84 | } catch (error) {
85 | lastError = error;
86 |
87 | if (attempt === maxRetries) {
88 | break;
89 | }
90 |
91 | onFailedTry(error);
92 |
93 | // Calculate exponential backoff with jitter
94 | const delay = Math.min(
95 | maxDelay,
96 | baseDelay * Math.pow(2, attempt) * (0.8 + Math.random() * 0.4),
97 | );
98 |
99 | await new Promise((resolve) => setTimeout(resolve, delay));
100 | }
101 | }
102 |
103 | throw lastError;
104 | }
105 |
106 | export default fetchClient;
107 |
```
--------------------------------------------------------------------------------
/packages/vue-sdk/test/usage.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { mount } from "@vue/test-utils";
2 | import { beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
3 | import { defineComponent, h, nextTick } from "vue";
4 |
5 | import { ReflagClient } from "@reflag/browser-sdk";
6 |
7 | import {
8 | ReflagBootstrappedProvider,
9 | ReflagProvider,
10 | useClient,
11 | useFlag,
12 | } from "../src";
13 |
14 | // Mock ReflagClient prototype methods like the React SDK tests
15 | beforeAll(() => {
16 | vi.spyOn(ReflagClient.prototype, "initialize").mockResolvedValue(undefined);
17 | vi.spyOn(ReflagClient.prototype, "stop").mockResolvedValue(undefined);
18 | vi.spyOn(ReflagClient.prototype, "getFlag").mockReturnValue({
19 | isEnabled: true,
20 | config: { key: "default", payload: { message: "Hello" } },
21 | track: vi.fn().mockResolvedValue(undefined),
22 | requestFeedback: vi.fn(),
23 | setIsEnabledOverride: vi.fn(),
24 | isEnabledOverride: null,
25 | });
26 | vi.spyOn(ReflagClient.prototype, "getFlags").mockReturnValue({});
27 | vi.spyOn(ReflagClient.prototype, "on").mockReturnValue(() => {
28 | // cleanup function
29 | });
30 | vi.spyOn(ReflagClient.prototype, "off").mockImplementation(() => {
31 | // off implementation
32 | });
33 | });
34 |
35 | beforeEach(() => {
36 | vi.clearAllMocks();
37 | });
38 |
39 | function getProvider() {
40 | return {
41 | props: {
42 | publishableKey: "key",
43 | },
44 | };
45 | }
46 |
47 | describe("ReflagProvider", () => {
48 | test("provides the client", async () => {
49 | const Child = defineComponent({
50 | setup() {
51 | const client = useClient();
52 | return { client };
53 | },
54 | template: "<div></div>",
55 | });
56 |
57 | const wrapper = mount(ReflagProvider, {
58 | ...getProvider(),
59 | slots: { default: () => h(Child) },
60 | });
61 |
62 | await nextTick();
63 | expect(wrapper.findComponent(Child).vm.client).toBeDefined();
64 | });
65 |
66 | test("throws without provider", () => {
67 | const Comp = defineComponent({
68 | setup() {
69 | return () => {
70 | useClient();
71 | };
72 | },
73 | });
74 |
75 | expect(() => mount(Comp)).toThrow();
76 | });
77 | });
78 |
79 | describe("ReflagBootstrappedProvider", () => {
80 | test("provides the client with bootstrapped flags", async () => {
81 | const bootstrappedFlags = {
82 | context: {
83 | user: { id: "test-user" },
84 | company: { id: "test-company" },
85 | },
86 | flags: {
87 | "test-flag": {
88 | key: "test-flag",
89 | isEnabled: true,
90 | config: { key: "default", payload: { message: "Hello" } },
91 | },
92 | },
93 | };
94 |
95 | const Child = defineComponent({
96 | setup() {
97 | const client = useClient();
98 | const flag = useFlag("test-flag");
99 | return { client, flag };
100 | },
101 | template: "<div></div>",
102 | });
103 |
104 | const wrapper = mount(ReflagBootstrappedProvider, {
105 | props: {
106 | publishableKey: "key",
107 | flags: bootstrappedFlags,
108 | },
109 | slots: { default: () => h(Child) },
110 | });
111 |
112 | await nextTick();
113 | expect(wrapper.findComponent(Child).vm.client).toBeDefined();
114 | expect(wrapper.findComponent(Child).vm.flag.isEnabled.value).toBe(true);
115 | });
116 | });
117 |
```
--------------------------------------------------------------------------------
/packages/cli/commands/init.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { input, select } from "@inquirer/prompts";
2 | import chalk from "chalk";
3 | import { Command } from "commander";
4 | import { relative } from "node:path";
5 | import ora, { Ora } from "ora";
6 |
7 | import { App, listApps } from "../services/bootstrap.js";
8 | import { configStore, typeFormats } from "../stores/config.js";
9 | import { DEFAULT_TYPES_OUTPUT } from "../utils/constants.js";
10 | import { handleError } from "../utils/errors.js";
11 | import { overwriteOption } from "../utils/options.js";
12 |
13 | type InitArgs = {
14 | overwrite?: boolean;
15 | };
16 |
17 | export const initAction = async (args: InitArgs = {}) => {
18 | let spinner: Ora | undefined;
19 | let apps: App[] = [];
20 |
21 | try {
22 | // Check if config already exists
23 | const configPath = configStore.getConfigPath();
24 | if (configPath && !args.overwrite) {
25 | throw new Error(
26 | "Reflag is already initialized. Use --overwrite to overwrite.",
27 | );
28 | }
29 |
30 | console.log("\nWelcome to ◪ Reflag!\n");
31 | const baseUrl = configStore.getConfig("baseUrl");
32 |
33 | // Load apps
34 | spinner = ora(`Loading apps from ${chalk.cyan(baseUrl)}...`).start();
35 | apps = listApps();
36 | spinner.succeed(`Loaded apps from ${chalk.cyan(baseUrl)}.`);
37 | } catch (error) {
38 | spinner?.fail("Loading apps failed.");
39 | handleError(error, "Initialization");
40 | }
41 |
42 | try {
43 | let appId: string | undefined;
44 | const nonDemoApp = apps.find((app) => !app.demo);
45 |
46 | if (apps.length === 0) {
47 | throw new Error("You don't have any apps yet. Please create one.");
48 | } else {
49 | const longestName = Math.max(...apps.map((app) => app.name.length));
50 | appId = await select({
51 | message: "Select an app",
52 | default: nonDemoApp?.id,
53 | choices: apps.map((app) => ({
54 | name: `${app.name.padEnd(longestName, " ")}${app.demo ? " [Demo]" : ""}`,
55 | value: app.id,
56 | })),
57 | });
58 | }
59 |
60 | // Get types output path
61 | const typesOutput = await input({
62 | message: "Where should we generate the types?",
63 | default: DEFAULT_TYPES_OUTPUT,
64 | });
65 |
66 | // Get types output format
67 | const typesFormat = await select({
68 | message: "What is the output format?",
69 | choices: typeFormats.map((format) => ({
70 | name: format,
71 | value: format,
72 | })),
73 | default: "react",
74 | });
75 |
76 | // Update config
77 | configStore.setConfig({
78 | appId,
79 | typesOutput: [{ path: typesOutput, format: typesFormat }],
80 | });
81 |
82 | // Create config file
83 | spinner = ora("Creating configuration...").start();
84 | await configStore.saveConfigFile(args.overwrite);
85 |
86 | spinner.succeed(
87 | `Configuration created at ${chalk.cyan(relative(process.cwd(), configStore.getConfigPath()!))}.`,
88 | );
89 | } catch (error) {
90 | spinner?.fail("Configuration creation failed.");
91 | handleError(error, "Initialization");
92 | }
93 | };
94 |
95 | export function registerInitCommand(cli: Command) {
96 | cli
97 | .command("init")
98 | .description("Initialize a new Reflag configuration.")
99 | .addOption(overwriteOption)
100 | .action(initAction);
101 | }
102 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/src/feedback/ui/FeedbackForm.css:
--------------------------------------------------------------------------------
```css
1 | .container {
2 | overflow-y: hidden;
3 | transition: max-height 400ms cubic-bezier(0.65, 0, 0.35, 1);
4 | }
5 |
6 | .form {
7 | display: flex;
8 | flex-direction: column;
9 | align-items: flex-start;
10 | gap: 10px;
11 | overflow-y: hidden;
12 | max-height: 400px;
13 | transition: opacity 400ms cubic-bezier(0.65, 0, 0.35, 1);
14 | }
15 |
16 | .form-control {
17 | display: flex;
18 | flex-direction: column;
19 | width: 100%;
20 | gap: 8px;
21 | border: none;
22 | padding: 0;
23 | margin: 0;
24 |
25 | font-size: 12px;
26 | color: var(--reflag-feedback-dialog-secondary-color, #787c91);
27 | }
28 |
29 | .form-expanded-content {
30 | width: 100%;
31 | display: flex;
32 | flex-direction: column;
33 | align-items: flex-start;
34 | gap: 10px;
35 | transition: opacity 400ms cubic-bezier(0.65, 0, 0.35, 1);
36 |
37 | opacity: 0;
38 | position: absolute;
39 | top: 0;
40 | left: 0;
41 | }
42 |
43 | .title {
44 | color: var(--reflag-feedback-dialog-color, #1e1f24);
45 | font-size: 15px;
46 | font-weight: 400;
47 | line-height: 115%;
48 | text-wrap: balance;
49 | max-width: calc(100% - 20px);
50 | margin-bottom: 6px;
51 | line-height: 1.3;
52 | }
53 |
54 | .dimmed {
55 | opacity: 0.5;
56 | }
57 |
58 | .textarea {
59 | background-color: transparent;
60 | border: 1px solid;
61 | border-color: var(--reflag-feedback-dialog-input-border-color, #d8d9df);
62 | padding: 0.5rem 0.75rem;
63 | border-radius: var(--reflag-feedback-dialog-border-radius, 6px);
64 | transition: border-color 0.2s ease-in-out;
65 | font-family: var(
66 | --reflag-feedback-dialog-font-family,
67 | InterVariable,
68 | Inter,
69 | system-ui,
70 | Open Sans,
71 | sans-serif
72 | );
73 | line-height: 1.3;
74 | resize: none;
75 |
76 | color: var(--reflag-feedback-dialog-color, #1e1f24);
77 | font-size: 13px;
78 |
79 | &::placeholder {
80 | color: var(--reflag-feedback-dialog-color, #1e1f24);
81 | opacity: 0.36;
82 | }
83 |
84 | &:focus {
85 | outline: none;
86 | border-color: var(
87 | --reflag-feedback-dialog-input-focus-border-color,
88 | #787c91
89 | );
90 | }
91 | }
92 |
93 | .score-status-container {
94 | position: relative;
95 | padding-bottom: 6px;
96 | height: 14px;
97 |
98 | > .score-status {
99 | display: flex;
100 | align-items: center;
101 |
102 | position: absolute;
103 | top: 0;
104 | left: 0;
105 |
106 | opacity: 0;
107 | transition: opacity 200ms ease-in-out;
108 | }
109 | }
110 |
111 | .error {
112 | margin: 0;
113 | color: var(--reflag-feedback-dialog-error-color, #e53e3e);
114 | font-size: 0.8125em;
115 | font-weight: 500;
116 | }
117 |
118 | .submitted {
119 | display: flex;
120 | flex-direction: column;
121 | transition: opacity 400ms cubic-bezier(0.65, 0, 0.35, 1);
122 |
123 | position: absolute;
124 | top: 0;
125 | left: 0;
126 | opacity: 0;
127 | pointer-events: none;
128 | width: calc(100% - 56px);
129 |
130 | padding: 0px 28px;
131 |
132 | .submitted-check {
133 | background: var(--reflag-feedback-dialog-submitted-check-color, #fff);
134 | color: var(
135 | --reflag-feedback-dialog-submitted-check-background-color,
136 | #38a169
137 | );
138 | height: 24px;
139 | width: 24px;
140 | display: block;
141 | border-radius: 50%;
142 | flex-shrink: 0;
143 | display: flex;
144 | align-items: center;
145 | justify-content: center;
146 |
147 | margin: 16px auto 8px;
148 | }
149 |
150 | .text {
151 | margin: auto auto 16px;
152 | text-align: center;
153 | color: var(--reflag-feedback-dialog-color, #1e1f24);
154 | font-size: var(--reflag-feedback-dialog-font-size, 1rem);
155 | font-weight: 400;
156 | line-height: 130%;
157 |
158 | flex-grow: 1;
159 | max-width: 160px;
160 | }
161 |
162 | > .plug {
163 | flex-grow: 0;
164 | }
165 | }
166 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/src/feedback/ui/FeedbackDialog.tsx:
--------------------------------------------------------------------------------
```typescript
1 | import { Fragment, FunctionComponent, h } from "preact";
2 | import { useCallback, useState } from "preact/hooks";
3 |
4 | import { feedbackContainerId } from "../../ui/constants";
5 | import { Dialog, useDialog } from "../../ui/Dialog";
6 | import { Close } from "../../ui/icons/Close";
7 |
8 | import { DEFAULT_TRANSLATIONS } from "./config/defaultTranslations";
9 | import { useTimer } from "./hooks/useTimer";
10 | import { FeedbackForm } from "./FeedbackForm";
11 | import styles from "./index.css?inline";
12 | import { RadialProgress } from "./RadialProgress";
13 | import {
14 | FeedbackScoreSubmission,
15 | FeedbackSubmission,
16 | OpenFeedbackFormOptions,
17 | WithRequired,
18 | } from "./types";
19 |
20 | export type FeedbackDialogProps = WithRequired<
21 | OpenFeedbackFormOptions,
22 | "onSubmit" | "position"
23 | >;
24 |
25 | const INACTIVE_DURATION_MS = 20 * 1000;
26 | const SUCCESS_DURATION_MS = 3 * 1000;
27 |
28 | export const FeedbackDialog: FunctionComponent<FeedbackDialogProps> = ({
29 | key,
30 | title = DEFAULT_TRANSLATIONS.DefaultQuestionLabel,
31 | position,
32 | translations = DEFAULT_TRANSLATIONS,
33 | openWithCommentVisible = false,
34 | onClose,
35 | onDismiss,
36 | onSubmit,
37 | onScoreSubmit,
38 | }) => {
39 | const [feedbackId, setFeedbackId] = useState<string | undefined>(undefined);
40 | const [scoreState, setScoreState] = useState<
41 | "idle" | "submitting" | "submitted"
42 | >("idle");
43 |
44 | const { isOpen, close } = useDialog({ onClose, initialValue: true });
45 |
46 | const autoClose = useTimer({
47 | enabled: position.type === "DIALOG",
48 | initialDuration: INACTIVE_DURATION_MS,
49 | onEnd: close,
50 | });
51 |
52 | const submit = useCallback(
53 | async (data: Omit<FeedbackSubmission, "feedbackId">) => {
54 | await onSubmit({ ...data, feedbackId });
55 | autoClose.startWithDuration(SUCCESS_DURATION_MS);
56 | },
57 | [autoClose, feedbackId, onSubmit],
58 | );
59 |
60 | const submitScore = useCallback(
61 | async (data: Omit<FeedbackScoreSubmission, "feedbackId">) => {
62 | if (onScoreSubmit !== undefined) {
63 | setScoreState("submitting");
64 |
65 | const res = await onScoreSubmit({ ...data, feedbackId });
66 | setFeedbackId(res.feedbackId);
67 | setScoreState("submitted");
68 | }
69 | },
70 | [feedbackId, onScoreSubmit],
71 | );
72 | const dismiss = useCallback(() => {
73 | autoClose.stop();
74 | close();
75 | onDismiss?.();
76 | }, [autoClose, close, onDismiss]);
77 |
78 | return (
79 | <>
80 | <style dangerouslySetInnerHTML={{ __html: styles }} />
81 | <Dialog
82 | key={key}
83 | close={close}
84 | containerId={feedbackContainerId}
85 | isOpen={isOpen}
86 | position={position}
87 | onDismiss={onDismiss}
88 | >
89 | <>
90 | <FeedbackForm
91 | key={key}
92 | openWithCommentVisible={openWithCommentVisible}
93 | question={title}
94 | scoreState={scoreState}
95 | t={{ ...DEFAULT_TRANSLATIONS, ...translations }}
96 | onInteraction={autoClose.stop}
97 | onScoreSubmit={submitScore}
98 | onSubmit={submit}
99 | />
100 |
101 | <button class="close" onClick={dismiss}>
102 | {!autoClose.stopped && autoClose.elapsedFraction > 0 && (
103 | <RadialProgress
104 | diameter={28}
105 | progress={1.0 - autoClose.elapsedFraction}
106 | />
107 | )}
108 | <Close />
109 | </button>
110 | </>
111 | </Dialog>
112 | </>
113 | );
114 | };
115 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/test/e2e/acceptance.browser.spec.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { randomUUID } from "crypto";
2 | import { expect, test } from "@playwright/test";
3 |
4 | import { API_BASE_URL } from "../../src/config";
5 |
6 | const KEY = randomUUID();
7 |
8 | test("Acceptance", async ({ page }) => {
9 | await page.goto("http://localhost:8001/test/e2e/empty.html");
10 |
11 | const successfulRequests: string[] = [];
12 |
13 | // Mock API calls with assertions
14 | await page.route(`${API_BASE_URL}/features/evaluated*`, async (route) => {
15 | successfulRequests.push("FLAGS");
16 | await route.fulfill({
17 | status: 200,
18 | body: JSON.stringify({
19 | success: true,
20 | features: {},
21 | }),
22 | });
23 | });
24 |
25 | await page.route(`${API_BASE_URL}/user`, async (route) => {
26 | expect(route.request().method()).toEqual("POST");
27 | expect(route.request().postDataJSON()).toMatchObject({
28 | userId: "foo",
29 | attributes: {
30 | name: "john doe",
31 | },
32 | });
33 |
34 | successfulRequests.push("USER");
35 | await route.fulfill({
36 | status: 200,
37 | body: JSON.stringify({ success: true }),
38 | });
39 | });
40 |
41 | await page.route(`${API_BASE_URL}/company`, async (route) => {
42 | expect(route.request().method()).toEqual("POST");
43 | expect(route.request().postDataJSON()).toMatchObject({
44 | userId: "foo",
45 | companyId: "bar",
46 | attributes: {
47 | name: "bar corp",
48 | },
49 | });
50 |
51 | successfulRequests.push("COMPANY");
52 | await route.fulfill({
53 | status: 200,
54 | body: JSON.stringify({ success: true }),
55 | });
56 | });
57 |
58 | await page.route(`${API_BASE_URL}/event`, async (route) => {
59 | expect(route.request().method()).toEqual("POST");
60 | expect(route.request().postDataJSON()).toMatchObject({
61 | userId: "foo",
62 | companyId: "bar",
63 | event: "baz",
64 | attributes: {
65 | baz: true,
66 | },
67 | });
68 |
69 | successfulRequests.push("EVENT");
70 | await route.fulfill({
71 | status: 200,
72 | body: JSON.stringify({ success: true }),
73 | });
74 | });
75 |
76 | await page.route(`${API_BASE_URL}/feedback`, async (route) => {
77 | expect(route.request().method()).toEqual("POST");
78 | expect(route.request().postDataJSON()).toMatchObject({
79 | userId: "foo",
80 | companyId: "bar",
81 | featureId: "featureId1",
82 | score: 5,
83 | comment: "test!",
84 | question: "actual question",
85 | promptedQuestion: "prompted question",
86 | });
87 |
88 | successfulRequests.push("FEEDBACK");
89 | await route.fulfill({
90 | status: 200,
91 | body: JSON.stringify({ success: true }),
92 | });
93 | });
94 |
95 | // Golden path requests
96 | await page.evaluate(`
97 | ;(async () => {
98 | const { ReflagClient } = await import("/dist/reflag-browser-sdk.mjs");
99 | const reflagClient = new ReflagClient({
100 | publishableKey: "${KEY}",
101 | user: {
102 | id: "foo",
103 | name: "john doe",
104 | },
105 | company: {
106 | id: "bar",
107 | name: "bar corp",
108 | }
109 | });
110 | await reflagClient.initialize();
111 | await reflagClient.track("baz", { baz: true }, "foo", "bar");
112 | await reflagClient.feedback({
113 | featureId: "featureId1",
114 | score: 5,
115 | comment: "test!",
116 | question: "actual question",
117 | promptedQuestion: "prompted question",
118 | });
119 | })()
120 | `);
121 |
122 | // Assert all API requests were made
123 | expect(successfulRequests).toEqual([
124 | "FLAGS",
125 | "USER",
126 | "COMPANY",
127 | "EVENT",
128 | "FEEDBACK",
129 | ]);
130 | });
131 |
```
--------------------------------------------------------------------------------
/packages/vue-sdk/dev/plain/components/FlagsList.vue:
--------------------------------------------------------------------------------
```vue
1 | <template>
2 | <Section title="Flags List">
3 | <div v-if="!client">
4 | <p>Client not available</p>
5 | </div>
6 | <div v-else>
7 | <p>This list shows all available flags and their current state:</p>
8 | <ul
9 | v-if="flagEntries.length > 0"
10 | style="list-style-type: none; padding: 0"
11 | >
12 | <li
13 | v-for="[flagKey, flag] in flagEntries"
14 | :key="flagKey"
15 | style="
16 | margin-bottom: 10px;
17 | padding: 10px;
18 | border: 1px solid #ccc;
19 | border-radius: 4px;
20 | "
21 | >
22 | <div style="display: flex; align-items: center; gap: 10px">
23 | <strong>{{ flagKey }}</strong>
24 | <span
25 | :style="{
26 | color:
27 | (flag.isEnabledOverride ?? flag.isEnabled) ? 'green' : 'red',
28 | }"
29 | >
30 | {{
31 | (flag.isEnabledOverride ?? flag.isEnabled)
32 | ? "Enabled"
33 | : "Disabled"
34 | }}
35 | </span>
36 |
37 | <!-- Reset button if override is active -->
38 | <button
39 | v-if="flag.isEnabledOverride !== null"
40 | style="margin-left: 10px; padding: 2px 8px; font-size: 12px"
41 | @click="() => resetOverride(flagKey)"
42 | >
43 | Reset
44 | </button>
45 |
46 | <!-- Toggle checkbox -->
47 | <input
48 | type="checkbox"
49 | :checked="flag.isEnabledOverride ?? flag.isEnabled"
50 | style="margin-left: auto"
51 | @change="
52 | (e) => {
53 | const isChecked = (e.target as HTMLInputElement).checked;
54 | const isEnabledOverride = flag.isEnabledOverride !== null;
55 | toggleFlag(flagKey, !isEnabledOverride ? isChecked : null);
56 | }
57 | "
58 | />
59 | </div>
60 |
61 | <!-- Show config if available -->
62 | <div
63 | v-if="flag.config && flag.config.key"
64 | style="margin-top: 5px; font-size: 12px; color: #666"
65 | >
66 | <strong>Config:</strong>
67 | <pre
68 | style="
69 | margin: 2px 0;
70 | padding: 4px;
71 | background: #f5f5f5;
72 | border-radius: 2px;
73 | overflow: auto;
74 | "
75 | >{{ JSON.stringify(flag.config.payload, null, 2) }}</pre
76 | >
77 | </div>
78 | </li>
79 | </ul>
80 | <p v-else style="color: #666; font-style: italic">No flags available</p>
81 | </div>
82 | </Section>
83 | </template>
84 |
85 | <script setup lang="ts">
86 | import { computed, ref } from "vue";
87 |
88 | import { useClient, useOnEvent } from "../../../src";
89 |
90 | import Section from "./Section.vue";
91 |
92 | const client = useClient();
93 | const flagsData = ref(client.getFlags());
94 |
95 | // Update flags data when flags are updated
96 | function updateFlags() {
97 | flagsData.value = client.getFlags();
98 | }
99 |
100 | // Update flags data when flags are updated
101 | useOnEvent("flagsUpdated", updateFlags);
102 |
103 | const flagEntries = computed(() => {
104 | return Object.entries(flagsData.value);
105 | });
106 |
107 | function resetOverride(flagKey: string) {
108 | client.getFlag(flagKey).setIsEnabledOverride(null);
109 | updateFlags();
110 | }
111 |
112 | function toggleFlag(flagKey: string, checked: boolean | null) {
113 | // Use simplified logic similar to React implementation
114 | client.getFlag(flagKey).setIsEnabledOverride(checked);
115 | updateFlags();
116 | }
117 | </script>
118 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/src/ui/packages/floating-ui-preact-dom/types.ts:
--------------------------------------------------------------------------------
```typescript
1 | import type {
2 | ComputePositionConfig,
3 | ComputePositionReturn,
4 | VirtualElement,
5 | } from "@floating-ui/dom";
6 | import { h, RefObject } from "preact";
7 |
8 | export { arrow, Options as ArrowOptions } from "./arrow";
9 | export { useFloating } from "./useFloating";
10 | export type {
11 | AlignedPlacement,
12 | Alignment,
13 | AutoPlacementOptions,
14 | AutoUpdateOptions,
15 | Axis,
16 | Boundary,
17 | ClientRectObject,
18 | ComputePositionConfig,
19 | ComputePositionReturn,
20 | Coords,
21 | DetectOverflowOptions,
22 | Dimensions,
23 | ElementContext,
24 | ElementRects,
25 | Elements,
26 | FlipOptions,
27 | FloatingElement,
28 | HideOptions,
29 | InlineOptions,
30 | Length,
31 | Middleware,
32 | MiddlewareArguments,
33 | MiddlewareData,
34 | MiddlewareReturn,
35 | MiddlewareState,
36 | NodeScroll,
37 | OffsetOptions,
38 | Padding,
39 | Placement,
40 | Platform,
41 | Rect,
42 | ReferenceElement,
43 | RootBoundary,
44 | ShiftOptions,
45 | Side,
46 | SideObject,
47 | SizeOptions,
48 | Strategy,
49 | VirtualElement,
50 | } from "@floating-ui/dom";
51 | export {
52 | autoPlacement,
53 | autoUpdate,
54 | computePosition,
55 | detectOverflow,
56 | flip,
57 | getOverflowAncestors,
58 | hide,
59 | inline,
60 | limitShift,
61 | offset,
62 | platform,
63 | shift,
64 | size,
65 | } from "@floating-ui/dom";
66 |
67 | type Prettify<T> = {
68 | [K in keyof T]: T[K];
69 | } & {};
70 |
71 | export type UseFloatingData = Prettify<
72 | ComputePositionReturn & { isPositioned: boolean }
73 | >;
74 |
75 | export type ReferenceType = Element | VirtualElement;
76 |
77 | export type UseFloatingReturn<RT extends ReferenceType = ReferenceType> =
78 | Prettify<
79 | UseFloatingData & {
80 | /**
81 | * Update the position of the floating element, re-rendering the component
82 | * if required.
83 | */
84 | update: () => void;
85 | /**
86 | * Pre-configured positioning styles to apply to the floating element.
87 | */
88 | floatingStyles: h.JSX.CSSProperties;
89 | /**
90 | * Object containing the reference and floating refs and reactive setters.
91 | */
92 | refs: {
93 | /**
94 | * A React ref to the reference element.
95 | */
96 | reference: RefObject<RT | null>;
97 | /**
98 | * A React ref to the floating element.
99 | */
100 | floating: RefObject<HTMLElement | null>;
101 | /**
102 | * A callback to set the reference element (reactive).
103 | */
104 | setReference: (node: RT | null) => void;
105 | /**
106 | * A callback to set the floating element (reactive).
107 | */
108 | setFloating: (node: HTMLElement | null) => void;
109 | };
110 | elements: {
111 | reference: RT | null;
112 | floating: HTMLElement | null;
113 | };
114 | }
115 | >;
116 |
117 | export type UseFloatingOptions<RT extends ReferenceType = ReferenceType> =
118 | Prettify<
119 | Partial<ComputePositionConfig> & {
120 | /**
121 | * A callback invoked when both the reference and floating elements are
122 | * mounted, and cleaned up when either is unmounted. This is useful for
123 | * setting up event listeners (e.g. pass `autoUpdate`).
124 | */
125 | whileElementsMounted?: (
126 | reference: RT,
127 | floating: HTMLElement,
128 | update: () => void,
129 | ) => () => void;
130 | elements?: {
131 | reference?: RT | null;
132 | floating?: HTMLElement | null;
133 | };
134 | /**
135 | * The `open` state of the floating element to synchronize with the
136 | * `isPositioned` value.
137 | */
138 | open?: boolean;
139 | /**
140 | * Whether to use `transform` for positioning instead of `top` and `left`
141 | * (layout) in the `floatingStyles` object.
142 | */
143 | transform?: boolean;
144 | }
145 | >;
146 |
```
--------------------------------------------------------------------------------
/packages/openfeature-node-provider/example/app.ts:
--------------------------------------------------------------------------------
```typescript
1 | import express from "express";
2 | import "./reflag";
3 | import { EvaluationContext, OpenFeature } from "@openfeature/server-sdk";
4 | import { CreateTodosConfig } from "./reflag";
5 |
6 | // In the following, we assume that targetingKey is a unique identifier for the user.
7 | type Context = EvaluationContext & {
8 | targetingKey: string;
9 | companyId: string;
10 | };
11 |
12 | // Augment the Express types to include the some context property on the `res.locals` object.
13 | declare global {
14 | namespace Express {
15 | interface Locals {
16 | context: Context;
17 | }
18 | }
19 | }
20 |
21 | const app = express();
22 |
23 | app.use(express.json());
24 |
25 | app.use((req, res, next) => {
26 | const ofContext = {
27 | targetingKey: "user42",
28 | companyId: "company99",
29 | };
30 | res.locals.context = ofContext;
31 | next();
32 | });
33 |
34 | const todos = ["Buy milk", "Walk the dog"];
35 |
36 | app.get("/", (_req, res) => {
37 | const ofClient = OpenFeature.getClient();
38 | ofClient.track("front-page-viewed", res.locals.context);
39 |
40 | res.json({ message: "Ready to manage some TODOs!" });
41 | });
42 |
43 | app.get("/todos", async (req, res) => {
44 | // Return todos if the feature is enabled for the user
45 | // We use the `getFlag` method to check if the user has the "show-todo" feature enabled.
46 | // Note that "show-todo" is a flag that we defined in the `Flags` interface in the `reflag.ts` file.
47 | // and that the indexing for flag name below is type-checked at compile time.
48 | const ofClient = OpenFeature.getClient();
49 | const isEnabled = await ofClient.getBooleanValue(
50 | "show-todos",
51 | false,
52 | res.locals.context,
53 | );
54 |
55 | if (isEnabled) {
56 | ofClient.track("show-todo", res.locals.context);
57 | return res.json({ todos });
58 | }
59 |
60 | return res
61 | .status(403)
62 | .json({ error: "You do not have access to this feature yet!" });
63 | });
64 |
65 | app.post("/todos", async (req, res) => {
66 | const { todo } = req.body;
67 |
68 | if (typeof todo !== "string") {
69 | return res.status(400).json({ error: "Invalid todo" });
70 | }
71 |
72 | const ofClient = OpenFeature.getClient();
73 | const isEnabled = await ofClient.getBooleanValue(
74 | "create-todo",
75 | false,
76 | res.locals.context,
77 | );
78 |
79 | // Check if the user has the "create-todos" feature enabled.
80 | if (isEnabled) {
81 | // Get the configuration for the "create-todos" feature.
82 | // We expect the configuration to be a JSON object with a `maxLength` property.
83 | const config = await ofClient.getObjectValue<CreateTodosConfig>(
84 | "create-todos",
85 | { maxLength: 100 },
86 | res.locals.context,
87 | );
88 |
89 | // Check if the todo is too long.
90 | if (todo.length > config.maxLength) {
91 | return res.status(400).json({ error: "Todo is too long" });
92 | }
93 |
94 | // Track the feature usage
95 | ofClient.track("create-todos", res.locals.context);
96 | todos.push(todo);
97 |
98 | return res.status(201).json({ todo });
99 | }
100 |
101 | res
102 | .status(403)
103 | .json({ error: "You do not have access to this feature yet!" });
104 | });
105 |
106 | app.delete("/todos/:idx", async (req, res) => {
107 | const idx = parseInt(req.params.idx);
108 |
109 | if (isNaN(idx) || idx < 0 || idx >= todos.length) {
110 | return res.status(400).json({ error: "Invalid index" });
111 | }
112 |
113 | const ofClient = OpenFeature.getClient();
114 | const isEnabled = await ofClient.getBooleanValue(
115 | "delete-todos",
116 | false,
117 | res.locals.context,
118 | );
119 |
120 | if (isEnabled) {
121 | todos.splice(idx, 1);
122 |
123 | ofClient.track("delete-todos", res.locals.context);
124 | return res.json({});
125 | }
126 |
127 | res
128 | .status(403)
129 | .json({ error: "You do not have access to this feature yet!" });
130 | });
131 |
132 | export default app;
133 |
```
--------------------------------------------------------------------------------
/packages/node-sdk/test/rate-limiter.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
2 |
3 | import { newRateLimiter } from "../src/rate-limiter";
4 |
5 | describe("rateLimiter", () => {
6 | beforeAll(() => {
7 | vi.useFakeTimers({ shouldAdvanceTime: true });
8 | });
9 |
10 | afterAll(() => {
11 | vi.useRealTimers();
12 | });
13 |
14 | const windowSizeMs = 1000;
15 |
16 | describe("isAllowed", () => {
17 | it("should rate limit", () => {
18 | const limiter = newRateLimiter(windowSizeMs);
19 |
20 | expect(limiter.isAllowed("key")).toBe(true);
21 | expect(limiter.isAllowed("key")).toBe(false);
22 | });
23 |
24 | it("should reset the limit in given time", () => {
25 | const limiter = newRateLimiter(windowSizeMs);
26 |
27 | limiter.isAllowed("key");
28 |
29 | vi.advanceTimersByTime(windowSizeMs);
30 | expect(limiter.isAllowed("key")).toBe(false);
31 |
32 | vi.advanceTimersByTime(1);
33 | expect(limiter.isAllowed("key")).toBe(true);
34 | });
35 |
36 | it("should measure events separately by key", () => {
37 | const limiter = newRateLimiter(windowSizeMs);
38 |
39 | expect(limiter.isAllowed("key1")).toBe(true);
40 |
41 | vi.advanceTimersByTime(windowSizeMs);
42 | expect(limiter.isAllowed("key2")).toBe(true);
43 | expect(limiter.isAllowed("key1")).toBe(false);
44 |
45 | vi.advanceTimersByTime(1);
46 | expect(limiter.isAllowed("key1")).toBe(true);
47 |
48 | vi.advanceTimersByTime(windowSizeMs);
49 | expect(limiter.isAllowed("key2")).toBe(true);
50 | });
51 | });
52 |
53 | describe("clearStale", () => {
54 | it("should clear expired events, but keep non-expired", () => {
55 | const rateLimiter = newRateLimiter(windowSizeMs);
56 | rateLimiter.isAllowed("key1");
57 | expect(rateLimiter.cacheSize()).toBe(1);
58 |
59 | vi.advanceTimersByTime(windowSizeMs / 2); // 500ms
60 | rateLimiter.isAllowed("key2");
61 | expect(rateLimiter.cacheSize()).toBe(2);
62 |
63 | vi.advanceTimersByTime(windowSizeMs / 2 + 1); // 1001ms total
64 | // at this point, key1 is stale, but key2 is not
65 |
66 | rateLimiter.clearStale();
67 | expect(rateLimiter.cacheSize()).toBe(1);
68 |
69 | // key2 should still be in the cache, and thus rate-limited
70 | expect(rateLimiter.isAllowed("key2")).toBe(false);
71 | // key1 should have been removed, so it's allowed again
72 | expect(rateLimiter.isAllowed("key1")).toBe(true);
73 | expect(rateLimiter.cacheSize()).toBe(2);
74 | });
75 | });
76 |
77 | it("should periodically clean up expired keys", () => {
78 | const mathRandomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5);
79 | const rateLimiter = newRateLimiter(windowSizeMs);
80 |
81 | // Add key1, cache size is 1.
82 | rateLimiter.isAllowed("key1");
83 | expect(rateLimiter.cacheSize()).toBe(1);
84 |
85 | // Advance time so key1 becomes stale.
86 | vi.advanceTimersByTime(windowSizeMs + 1);
87 |
88 | // Trigger another call for a different key.
89 | // This should not clear anything, cache size becomes 2.
90 | rateLimiter.isAllowed("key2");
91 | expect(rateLimiter.cacheSize()).toBe(2);
92 |
93 | // Mock random to trigger clearStale on the next call.
94 | mathRandomSpy.mockReturnValue(0.005);
95 |
96 | // This call for a new key ("key3") should trigger a cleanup.
97 | // "key1" is stale and will be cleared. "key2" remains. "key3" is added.
98 | // Cache size should go from 2 -> 1 (clear) -> 2 (add).
99 | rateLimiter.isAllowed("key3");
100 | expect(rateLimiter.cacheSize()).toBe(2);
101 |
102 | // To confirm "key1" was cleared, we should be able to add it again.
103 | expect(rateLimiter.isAllowed("key1")).toBe(true);
104 | expect(rateLimiter.cacheSize()).toBe(3);
105 |
106 | mathRandomSpy.mockRestore();
107 | });
108 | });
109 |
```
--------------------------------------------------------------------------------
/packages/cli/commands/rules.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { confirm } from "@inquirer/prompts";
2 | import chalk from "chalk";
3 | import { Command } from "commander";
4 | import { mkdir, readFile, writeFile } from "node:fs/promises";
5 | import { dirname, join, relative } from "node:path";
6 | import ora from "ora";
7 |
8 | import { getCopilotInstructions, getCursorRules } from "../services/rules.js";
9 | import { configStore } from "../stores/config.js";
10 | import { handleError } from "../utils/errors.js";
11 | import { fileExists } from "../utils/file.js";
12 | import { rulesFormatOption, yesOption } from "../utils/options.js";
13 |
14 | type RulesArgs = {
15 | format?: string;
16 | yes?: boolean;
17 | };
18 |
19 | const REFLAG_SECTION_START = "<!-- REFLAG_START -->";
20 | const REFLAG_SECTION_END = "<!-- REFLAG_END -->";
21 |
22 | async function confirmOverwrite(
23 | filePath: string,
24 | yes: boolean,
25 | append: boolean = false,
26 | ): Promise<boolean> {
27 | if (yes) return true;
28 |
29 | if (await fileExists(filePath)) {
30 | const projectPath = configStore.getProjectPath();
31 | const relativePath = relative(projectPath, filePath);
32 |
33 | return await confirm({
34 | message: `Rules ${chalk.cyan(relativePath)} already exists. ${
35 | append ? "Append rules?" : "Overwrite rules?"
36 | }`,
37 | default: false,
38 | });
39 | }
40 |
41 | return true;
42 | }
43 |
44 | function wrapInMarkers(content: string): string {
45 | return `${REFLAG_SECTION_START}\n\n${content}\n\n${REFLAG_SECTION_END}`;
46 | }
47 |
48 | function replaceOrAppendSection(
49 | existingContent: string,
50 | newContent: string,
51 | ): string {
52 | const wrappedContent = wrapInMarkers(newContent);
53 | const sectionRegex = new RegExp(
54 | `${REFLAG_SECTION_START}[\\s\\S]*?${REFLAG_SECTION_END}`,
55 | "g",
56 | );
57 |
58 | if (sectionRegex.test(existingContent)) {
59 | return existingContent.replace(sectionRegex, wrappedContent);
60 | }
61 |
62 | return `${existingContent}\n\n${wrappedContent}`;
63 | }
64 |
65 | export const rulesAction = async ({
66 | format = "cursor",
67 | yes = false,
68 | }: RulesArgs = {}) => {
69 | const projectPath = configStore.getProjectPath();
70 | const appendFormats = ["copilot"];
71 | let destPath: string;
72 | let content: string;
73 |
74 | // Determine destination and content based on format
75 | if (format === "cursor") {
76 | destPath = join(projectPath, ".cursor", "rules", "reflag.mdc");
77 | content = getCursorRules();
78 | } else if (format === "copilot") {
79 | destPath = join(projectPath, ".github", "copilot-instructions.md");
80 | content = getCopilotInstructions();
81 | } else {
82 | console.error(`No rules added. Invalid format ${chalk.cyan(format)}.`);
83 | return;
84 | }
85 |
86 | // Check for overwrite and write file
87 | if (await confirmOverwrite(destPath, yes, appendFormats.includes(format))) {
88 | const spinner = ora("Adding rules...").start();
89 | try {
90 | await mkdir(dirname(destPath), { recursive: true });
91 |
92 | if (appendFormats.includes(format) && (await fileExists(destPath))) {
93 | const existingContent = await readFile(destPath, "utf-8");
94 | content = replaceOrAppendSection(existingContent, content);
95 | }
96 |
97 | await writeFile(destPath, content);
98 | spinner.succeed(
99 | `Rules added to ${chalk.cyan(relative(projectPath, destPath))}.
100 | ${chalk.grey("These rules should be committed to your project's version control.")}`,
101 | );
102 | } catch (error) {
103 | spinner.fail("Failed to add rules.");
104 | handleError(error, "Rules");
105 | }
106 | } else {
107 | console.log("Skipping adding rules.");
108 | }
109 | };
110 |
111 | export function registerRulesCommand(cli: Command) {
112 | cli
113 | .command("rules")
114 | .description("Add Reflag LLM rules to your project.")
115 | .addOption(rulesFormatOption)
116 | .addOption(yesOption)
117 | .action(rulesAction);
118 | }
119 |
```
--------------------------------------------------------------------------------
/packages/vue-sdk/src/types.ts:
--------------------------------------------------------------------------------
```typescript
1 | import type { Ref } from "vue";
2 |
3 | import type {
4 | CompanyContext,
5 | InitOptions,
6 | RawFlags,
7 | ReflagClient,
8 | ReflagContext,
9 | RequestFeedbackData,
10 | UserContext,
11 | } from "@reflag/browser-sdk";
12 |
13 | export type EmptyFlagRemoteConfig = { key: undefined; payload: undefined };
14 |
15 | export type FlagType = {
16 | config?: {
17 | payload: any;
18 | };
19 | };
20 |
21 | export type FlagRemoteConfig =
22 | | {
23 | key: string;
24 | payload: any;
25 | }
26 | | EmptyFlagRemoteConfig;
27 |
28 | export interface Flag<
29 | TConfig extends FlagType["config"] = EmptyFlagRemoteConfig,
30 | > {
31 | key: string;
32 | isEnabled: Ref<boolean>;
33 | isLoading: Ref<boolean>;
34 | config: Ref<({ key: string } & TConfig) | EmptyFlagRemoteConfig>;
35 | track(): Promise<Response | undefined> | undefined;
36 | requestFeedback: (opts: RequestFlagFeedbackOptions) => void;
37 | }
38 |
39 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type
40 | export interface Flags {}
41 |
42 | export type TypedFlags = keyof Flags extends never
43 | ? Record<string, Flag>
44 | : {
45 | [TypedFlagKey in keyof Flags]: Flags[TypedFlagKey] extends FlagType
46 | ? Flag<Flags[TypedFlagKey]["config"]>
47 | : Flag;
48 | };
49 |
50 | export type FlagKey = keyof TypedFlags;
51 |
52 | export interface ProviderContextType {
53 | client: ReflagClient;
54 | isLoading: Ref<boolean>;
55 | }
56 |
57 | export type BootstrappedFlags = {
58 | context: ReflagContext;
59 | flags: RawFlags;
60 | };
61 |
62 | export type RequestFlagFeedbackOptions = Omit<
63 | RequestFeedbackData,
64 | "flagKey" | "featureId"
65 | >;
66 |
67 | /**
68 | * Base init options for the ReflagProvider and ReflagBootstrappedProvider.
69 | * @internal
70 | */
71 | export type ReflagInitOptionsBase = Omit<
72 | InitOptions,
73 | "user" | "company" | "other" | "otherContext" | "bootstrappedFlags"
74 | >;
75 |
76 | /**
77 | * Base props for the ReflagProvider and ReflagBootstrappedProvider.
78 | * @internal
79 | */
80 | export type ReflagBaseProps = {
81 | /**
82 | * Set to `true` to show the loading component while the client is initializing.
83 | */
84 | initialLoading?: boolean;
85 |
86 | /**
87 | * Set to `true` to enable debug logging to the console.
88 | */
89 | debug?: boolean;
90 | };
91 |
92 | /**
93 | * Props for the ReflagClientProvider.
94 | */
95 | export type ReflagClientProviderProps = Omit<ReflagBaseProps, "debug"> & {
96 | /**
97 | * A pre-initialized ReflagClient to use.
98 | */
99 | client: ReflagClient;
100 | };
101 |
102 | /**
103 | * Props for the ReflagProvider.
104 | */
105 | export type ReflagProps = ReflagInitOptionsBase &
106 | ReflagBaseProps & {
107 | /**
108 | * The context to use for the ReflagClient containing user, company, and other context.
109 | */
110 | context?: ReflagContext;
111 |
112 | /**
113 | * Company related context. If you provide `id` Reflag will enrich the evaluation context with
114 | * company attributes on Reflag servers.
115 | * @deprecated Use `context` instead, this property will be removed in the next major version
116 | */
117 | company?: CompanyContext;
118 |
119 | /**
120 | * User related context. If you provide `id` Reflag will enrich the evaluation context with
121 | * user attributes on Reflag servers.
122 | * @deprecated Use `context` instead, this property will be removed in the next major version
123 | */
124 | user?: UserContext;
125 |
126 | /**
127 | * Context which is not related to a user or a company.
128 | * @deprecated Use `context` instead, this property will be removed in the next major version
129 | */
130 | otherContext?: Record<string, string | number | undefined>;
131 | };
132 |
133 | /**
134 | * Props for the ReflagBootstrappedProvider.
135 | */
136 | export type ReflagBootstrappedProps = ReflagInitOptionsBase &
137 | ReflagBaseProps & {
138 | /**
139 | * Pre-fetched flags to be used instead of fetching them from the server.
140 | */
141 | flags: BootstrappedFlags;
142 | };
143 |
```
--------------------------------------------------------------------------------
/packages/node-sdk/test/inRequestCache.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import {
2 | afterAll,
3 | afterEach,
4 | beforeAll,
5 | beforeEach,
6 | describe,
7 | expect,
8 | it,
9 | vi,
10 | } from "vitest";
11 |
12 | import cache from "../src/inRequestCache";
13 | import { Logger } from "../src/types";
14 |
15 | describe("inRequestCache", () => {
16 | let fn: () => Promise<number>;
17 | let logger: Logger;
18 |
19 | beforeAll(() => {
20 | vi.useFakeTimers({ shouldAdvanceTime: true });
21 | });
22 |
23 | afterAll(() => {
24 | vi.useRealTimers();
25 | });
26 |
27 | beforeEach(() => {
28 | fn = vi.fn().mockResolvedValue(42);
29 | logger = {
30 | debug: vi.fn(),
31 | info: vi.fn(),
32 | warn: vi.fn(),
33 | error: vi.fn(),
34 | };
35 | });
36 |
37 | it("should update the cached value when refreshing", async () => {
38 | const cached = cache(1000, logger, fn);
39 |
40 | const result = await cached.refresh();
41 |
42 | expect(result).toBe(42);
43 | expect(logger.debug).toHaveBeenCalledWith(
44 | expect.stringMatching("inRequestCache: fetched value"),
45 | 42,
46 | );
47 | });
48 |
49 | it("should not allow multiple refreses at the same time", async () => {
50 | const cached = cache(1000, logger, fn);
51 |
52 | void cached.refresh();
53 | void cached.refresh();
54 | void cached.refresh();
55 | await cached.refresh();
56 |
57 | expect(fn).toHaveBeenCalledTimes(1);
58 | expect(logger.debug).toHaveBeenNthCalledWith(
59 | 1,
60 | expect.stringMatching("inRequestCache: fetched value"),
61 | 42,
62 | );
63 |
64 | void cached.refresh();
65 | await cached.refresh();
66 |
67 | expect(fn).toHaveBeenCalledTimes(2);
68 | expect(logger.debug).toHaveBeenNthCalledWith(
69 | 2,
70 | expect.stringMatching("inRequestCache: fetched value"),
71 | 42,
72 | );
73 | });
74 |
75 | it("should warn if the cached value is stale", async () => {
76 | const cached = cache(1000, logger, fn);
77 |
78 | await cached.refresh();
79 |
80 | vi.advanceTimersByTime(1100);
81 |
82 | const result = cached.get();
83 |
84 | expect(result).toBe(42);
85 | expect(logger.debug).toHaveBeenCalledWith(
86 | expect.stringMatching(
87 | "inRequestCache: stale value, triggering background refresh",
88 | ),
89 | );
90 | });
91 |
92 | it("should handle update failures gracefully", async () => {
93 | const error = new Error("update failed");
94 | fn = vi.fn().mockRejectedValueOnce(error).mockResolvedValueOnce(42);
95 |
96 | const cached = cache(1000, logger, fn);
97 |
98 | const first = await cached.refresh();
99 |
100 | expect(first).toBeUndefined();
101 | expect(logger.error).toHaveBeenCalledWith(
102 | expect.stringMatching("inRequestCache: error refreshing value"),
103 | error,
104 | );
105 | expect(fn).toHaveBeenCalledTimes(1);
106 |
107 | await cached.refresh();
108 |
109 | expect(fn).toHaveBeenCalledTimes(2);
110 | expect(logger.debug).toHaveBeenCalledWith(
111 | expect.stringMatching("inRequestCache: fetched value"),
112 | 42,
113 | );
114 |
115 | const second = cached.get();
116 | expect(second).toBe(42);
117 | });
118 |
119 | it("should retain the cached value if the new value is undefined", async () => {
120 | fn = vi.fn().mockResolvedValueOnce(42).mockResolvedValueOnce(undefined);
121 | const cached = cache(1000, logger, fn);
122 |
123 | await cached.refresh();
124 |
125 | const second = cached.get();
126 | expect(second).toBe(42);
127 |
128 | // error refreshing
129 | await cached.refresh();
130 |
131 | // should still be the old value
132 | const result = cached.get();
133 |
134 | expect(result).toBe(42);
135 | });
136 |
137 | it("should not update if cached value is still valid", async () => {
138 | const cached = cache(1000, logger, fn);
139 |
140 | const first = await cached.refresh();
141 |
142 | vi.advanceTimersByTime(500);
143 |
144 | const second = cached.get();
145 |
146 | expect(first).toBe(second);
147 | expect(logger.debug).toHaveBeenCalledTimes(1); // Only one update call
148 | });
149 |
150 | afterEach(() => {
151 | vi.clearAllTimers();
152 | vi.restoreAllMocks();
153 | });
154 | });
155 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/src/flag/flagCache.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { RawFlags } from "./flags";
2 |
3 | interface StorageItem {
4 | get(): string | null;
5 | set(value: string): void;
6 | }
7 |
8 | interface cacheEntry {
9 | expireAt: number;
10 | staleAt: number;
11 | flags: RawFlags;
12 | }
13 |
14 | // Parse and validate an API flags response
15 | export function parseAPIFlagsResponse(flagsInput: any): RawFlags | undefined {
16 | if (!isObject(flagsInput)) {
17 | return;
18 | }
19 |
20 | const flags: RawFlags = {};
21 | for (const key in flagsInput) {
22 | const flag = flagsInput[key];
23 |
24 | if (
25 | typeof flag.isEnabled !== "boolean" ||
26 | flag.key !== key ||
27 | typeof flag.targetingVersion !== "number" ||
28 | (flag.config && typeof flag.config !== "object") ||
29 | (flag.missingContextFields &&
30 | !Array.isArray(flag.missingContextFields)) ||
31 | (flag.ruleEvaluationResults && !Array.isArray(flag.ruleEvaluationResults))
32 | ) {
33 | return;
34 | }
35 |
36 | flags[key] = {
37 | isEnabled: flag.isEnabled,
38 | targetingVersion: flag.targetingVersion,
39 | key,
40 | config: flag.config,
41 | missingContextFields: flag.missingContextFields,
42 | ruleEvaluationResults: flag.ruleEvaluationResults,
43 | };
44 | }
45 |
46 | return flags;
47 | }
48 |
49 | export interface CacheResult {
50 | flags: RawFlags;
51 | stale: boolean;
52 | }
53 |
54 | export class FlagCache {
55 | private storage: StorageItem;
56 | private readonly staleTimeMs: number;
57 | private readonly expireTimeMs: number;
58 |
59 | constructor({
60 | storage,
61 | staleTimeMs,
62 | expireTimeMs,
63 | }: {
64 | storage: StorageItem;
65 | staleTimeMs: number;
66 | expireTimeMs: number;
67 | }) {
68 | this.storage = storage;
69 | this.staleTimeMs = staleTimeMs;
70 | this.expireTimeMs = expireTimeMs;
71 | }
72 |
73 | set(
74 | key: string,
75 | {
76 | flags,
77 | }: {
78 | flags: RawFlags;
79 | },
80 | ) {
81 | let cacheData: CacheData = {};
82 |
83 | try {
84 | const cachedResponseRaw = this.storage.get();
85 | if (cachedResponseRaw) {
86 | cacheData = validateCacheData(JSON.parse(cachedResponseRaw)) ?? {};
87 | }
88 | } catch {
89 | // ignore errors
90 | }
91 |
92 | cacheData[key] = {
93 | expireAt: Date.now() + this.expireTimeMs,
94 | staleAt: Date.now() + this.staleTimeMs,
95 | flags,
96 | } satisfies cacheEntry;
97 |
98 | cacheData = Object.fromEntries(
99 | Object.entries(cacheData).filter(([_k, v]) => v.expireAt > Date.now()),
100 | );
101 |
102 | this.storage.set(JSON.stringify(cacheData));
103 |
104 | return cacheData;
105 | }
106 |
107 | get(key: string): CacheResult | undefined {
108 | try {
109 | const cachedResponseRaw = this.storage.get();
110 | if (cachedResponseRaw) {
111 | const cachedResponse = validateCacheData(JSON.parse(cachedResponseRaw));
112 | if (
113 | cachedResponse &&
114 | cachedResponse[key] &&
115 | cachedResponse[key].expireAt > Date.now()
116 | ) {
117 | return {
118 | flags: cachedResponse[key].flags,
119 | stale: cachedResponse[key].staleAt < Date.now(),
120 | };
121 | }
122 | }
123 | } catch {
124 | // ignore errors
125 | }
126 | return;
127 | }
128 | }
129 |
130 | type CacheData = Record<string, cacheEntry>;
131 | function validateCacheData(cacheDataInput: any) {
132 | if (!isObject(cacheDataInput)) {
133 | return;
134 | }
135 |
136 | const cacheData: CacheData = {};
137 | for (const key in cacheDataInput) {
138 | const cacheEntry = cacheDataInput[key];
139 | if (!isObject(cacheEntry)) return;
140 |
141 | if (
142 | typeof cacheEntry.expireAt !== "number" ||
143 | typeof cacheEntry.staleAt !== "number" ||
144 | (cacheEntry.flags && !parseAPIFlagsResponse(cacheEntry.flags))
145 | ) {
146 | return;
147 | }
148 |
149 | cacheData[key] = {
150 | expireAt: cacheEntry.expireAt,
151 | staleAt: cacheEntry.staleAt,
152 | flags: cacheEntry.flags,
153 | };
154 | }
155 | return cacheData;
156 | }
157 |
158 | /**
159 | * Check if the given item is an object.
160 | *
161 | * @param item - The item to check.
162 | * @returns `true` if the item is an object, `false` otherwise.
163 | **/
164 | export function isObject(item: any): item is Record<string, any> {
165 | return (item && typeof item === "object" && !Array.isArray(item)) || false;
166 | }
167 |
```