This is page 4 of 9. Use http://codebase.md/bucketco/bucket-javascript-sdk?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .editorconfig
├── .gitattributes
├── .github
│ └── workflows
│ ├── package-ci.yml
│ └── publish.yml
├── .gitignore
├── .nvmrc
├── .prettierignore
├── .vscode
│ ├── extensions.json
│ └── settings.json
├── .yarnrc.yml
├── docs.sh
├── lerna.json
├── LICENSE
├── package.json
├── packages
│ ├── browser-sdk
│ │ ├── .prettierignore
│ │ ├── eslint.config.js
│ │ ├── example
│ │ │ ├── feedback
│ │ │ │ ├── feedback.html
│ │ │ │ └── Feedback.jsx
│ │ │ └── typescript
│ │ │ ├── app.ts
│ │ │ └── index.html
│ │ ├── FEEDBACK.md
│ │ ├── index.html
│ │ ├── package.json
│ │ ├── playwright.config.ts
│ │ ├── postcss.config.js
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── client.ts
│ │ │ ├── config.ts
│ │ │ ├── context.ts
│ │ │ ├── feedback
│ │ │ │ ├── feedback.ts
│ │ │ │ ├── prompts.ts
│ │ │ │ ├── promptStorage.ts
│ │ │ │ └── ui
│ │ │ │ ├── Button.css
│ │ │ │ ├── Button.tsx
│ │ │ │ ├── config
│ │ │ │ │ └── defaultTranslations.tsx
│ │ │ │ ├── css.d.ts
│ │ │ │ ├── FeedbackDialog.css
│ │ │ │ ├── FeedbackDialog.tsx
│ │ │ │ ├── FeedbackForm.css
│ │ │ │ ├── FeedbackForm.tsx
│ │ │ │ ├── hooks
│ │ │ │ │ └── useTimer.ts
│ │ │ │ ├── index.css
│ │ │ │ ├── index.ts
│ │ │ │ ├── Plug.tsx
│ │ │ │ ├── RadialProgress.css
│ │ │ │ ├── RadialProgress.tsx
│ │ │ │ ├── StarRating.css
│ │ │ │ ├── StarRating.tsx
│ │ │ │ └── types.ts
│ │ │ ├── flag
│ │ │ │ ├── flagCache.ts
│ │ │ │ └── flags.ts
│ │ │ ├── hooksManager.ts
│ │ │ ├── httpClient.ts
│ │ │ ├── index.ts
│ │ │ ├── logger.ts
│ │ │ ├── rateLimiter.ts
│ │ │ ├── sse.ts
│ │ │ ├── toolbar
│ │ │ │ ├── Flags.css
│ │ │ │ ├── Flags.tsx
│ │ │ │ ├── index.css
│ │ │ │ ├── index.ts
│ │ │ │ ├── Switch.css
│ │ │ │ ├── Switch.tsx
│ │ │ │ ├── Toolbar.css
│ │ │ │ └── Toolbar.tsx
│ │ │ └── ui
│ │ │ ├── constants.ts
│ │ │ ├── Dialog.css
│ │ │ ├── Dialog.tsx
│ │ │ ├── icons
│ │ │ │ ├── Check.tsx
│ │ │ │ ├── CheckCircle.tsx
│ │ │ │ ├── Close.tsx
│ │ │ │ ├── Dissatisfied.tsx
│ │ │ │ ├── Logo.tsx
│ │ │ │ ├── Neutral.tsx
│ │ │ │ ├── Satisfied.tsx
│ │ │ │ ├── VeryDissatisfied.tsx
│ │ │ │ └── VerySatisfied.tsx
│ │ │ ├── packages
│ │ │ │ └── floating-ui-preact-dom
│ │ │ │ ├── arrow.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── README.md
│ │ │ │ ├── types.ts
│ │ │ │ ├── useFloating.ts
│ │ │ │ └── utils
│ │ │ │ ├── deepEqual.ts
│ │ │ │ ├── getDPR.ts
│ │ │ │ ├── roundByDPR.ts
│ │ │ │ └── useLatestRef.ts
│ │ │ ├── types.ts
│ │ │ └── utils.ts
│ │ ├── test
│ │ │ ├── client.test.ts
│ │ │ ├── e2e
│ │ │ │ ├── acceptance.browser.spec.ts
│ │ │ │ ├── empty.html
│ │ │ │ ├── feedback-widget.browser.spec.ts
│ │ │ │ └── give-feedback-button.html
│ │ │ ├── flagCache.test.ts
│ │ │ ├── flags.test.ts
│ │ │ ├── hooksManager.test.ts
│ │ │ ├── httpClient.test.ts
│ │ │ ├── init.test.ts
│ │ │ ├── mocks
│ │ │ │ ├── handlers.ts
│ │ │ │ └── server.ts
│ │ │ ├── prompts.test.ts
│ │ │ ├── promptStorage.test.ts
│ │ │ ├── rateLimiter.test.ts
│ │ │ ├── sse.test.ts
│ │ │ ├── testLogger.ts
│ │ │ └── usage.test.ts
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ ├── typedoc.json
│ │ ├── vite.config.mjs
│ │ ├── vite.e2e.config.js
│ │ └── vitest.setup.ts
│ ├── cli
│ │ ├── .prettierignore
│ │ ├── commands
│ │ │ ├── apps.ts
│ │ │ ├── auth.ts
│ │ │ ├── flags.ts
│ │ │ ├── init.ts
│ │ │ ├── mcp.ts
│ │ │ ├── new.ts
│ │ │ └── rules.ts
│ │ ├── eslint.config.js
│ │ ├── index.ts
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── schema.json
│ │ ├── services
│ │ │ ├── bootstrap.ts
│ │ │ ├── flags.ts
│ │ │ ├── mcp.ts
│ │ │ └── rules.ts
│ │ ├── stores
│ │ │ ├── auth.ts
│ │ │ └── config.ts
│ │ ├── test
│ │ │ └── json.test.ts
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ ├── utils
│ │ │ ├── auth.ts
│ │ │ ├── commander.ts
│ │ │ ├── constants.ts
│ │ │ ├── errors.ts
│ │ │ ├── file.ts
│ │ │ ├── gen.ts
│ │ │ ├── json.ts
│ │ │ ├── options.ts
│ │ │ ├── schemas.ts
│ │ │ ├── types.ts
│ │ │ ├── urls.ts
│ │ │ └── version.ts
│ │ └── vite.config.js
│ ├── eslint-config
│ │ ├── base.js
│ │ └── package.json
│ ├── flag-evaluation
│ │ ├── .prettierignore
│ │ ├── eslint.config.js
│ │ ├── jest.config.js
│ │ ├── package.json
│ │ ├── src
│ │ │ └── index.ts
│ │ ├── test
│ │ │ └── index.test.ts
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ └── tsconfig.json
│ ├── node-sdk
│ │ ├── .prettierignore
│ │ ├── docs
│ │ │ ├── type-check-failed.png
│ │ │ └── type-check-payload-failed.png
│ │ ├── eslint.config.js
│ │ ├── examples
│ │ │ ├── cloudflare-worker
│ │ │ │ ├── .gitignore
│ │ │ │ ├── .prettierignore
│ │ │ │ ├── .vscode
│ │ │ │ │ └── settings.json
│ │ │ │ ├── package.json
│ │ │ │ ├── README.md
│ │ │ │ ├── src
│ │ │ │ │ └── index.ts
│ │ │ │ ├── tsconfig.json
│ │ │ │ ├── vitest.config.mts
│ │ │ │ ├── worker-configuration.d.ts
│ │ │ │ ├── wrangler.jsonc
│ │ │ │ └── yarn.lock
│ │ │ └── express
│ │ │ ├── app.test.ts
│ │ │ ├── app.ts
│ │ │ ├── bucket.ts
│ │ │ ├── bucketConfig.json
│ │ │ ├── package.json
│ │ │ ├── README.md
│ │ │ ├── serve.ts
│ │ │ ├── tsconfig.json
│ │ │ └── yarn.lock
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── batch-buffer.ts
│ │ │ ├── client.ts
│ │ │ ├── config.ts
│ │ │ ├── edgeClient.ts
│ │ │ ├── fetch-http-client.ts
│ │ │ ├── flusher.ts
│ │ │ ├── index.ts
│ │ │ ├── inRequestCache.ts
│ │ │ ├── periodicallyUpdatingCache.ts
│ │ │ ├── rate-limiter.ts
│ │ │ ├── types.ts
│ │ │ └── utils.ts
│ │ ├── test
│ │ │ ├── batch-buffer.test.ts
│ │ │ ├── client.test.ts
│ │ │ ├── config.test.ts
│ │ │ ├── fetch-http-client.test.ts
│ │ │ ├── flusher.test.ts
│ │ │ ├── inRequestCache.test.ts
│ │ │ ├── periodicallyUpdatingCache.test.ts
│ │ │ ├── rate-limiter.test.ts
│ │ │ ├── testConfig.json
│ │ │ └── utils.test.ts
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ ├── typedoc.json
│ │ └── vite.config.js
│ ├── openfeature-browser-provider
│ │ ├── .prettierignore
│ │ ├── eslint.config.js
│ │ ├── example
│ │ │ ├── .eslintrc.json
│ │ │ ├── .gitignore
│ │ │ ├── app
│ │ │ │ ├── featureManagement.ts
│ │ │ │ ├── globals.css
│ │ │ │ ├── layout.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── components
│ │ │ │ ├── Context.tsx
│ │ │ │ ├── HuddleFeature.tsx
│ │ │ │ └── OpenFeatureProvider.tsx
│ │ │ ├── next.config.mjs
│ │ │ ├── package.json
│ │ │ ├── postcss.config.mjs
│ │ │ ├── README.md
│ │ │ ├── tailwind.config.ts
│ │ │ └── tsconfig.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── index.test.ts
│ │ │ └── index.ts
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ └── vite.config.js
│ ├── openfeature-node-provider
│ │ ├── .prettierignore
│ │ ├── eslint.config.js
│ │ ├── example
│ │ │ ├── app.ts
│ │ │ ├── package.json
│ │ │ ├── README.md
│ │ │ ├── reflag.ts
│ │ │ ├── serve.ts
│ │ │ ├── tsconfig.json
│ │ │ └── yarn.lock
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── index.test.ts
│ │ │ └── index.ts
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ └── vite.config.js
│ ├── react-sdk
│ │ ├── .prettierignore
│ │ ├── dev
│ │ │ ├── .env
│ │ │ ├── nextjs-bootstrap-demo
│ │ │ │ ├── .eslintrc.json
│ │ │ │ ├── .gitignore
│ │ │ │ ├── app
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── favicon.ico
│ │ │ │ │ ├── globals.css
│ │ │ │ │ ├── layout.tsx
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── components
│ │ │ │ │ └── Flags.tsx
│ │ │ │ ├── next.config.mjs
│ │ │ │ ├── package.json
│ │ │ │ ├── postcss.config.mjs
│ │ │ │ ├── public
│ │ │ │ │ ├── next.svg
│ │ │ │ │ └── vercel.svg
│ │ │ │ ├── README.md
│ │ │ │ ├── tailwind.config.ts
│ │ │ │ └── tsconfig.json
│ │ │ ├── nextjs-flag-demo
│ │ │ │ ├── .eslintrc.json
│ │ │ │ ├── .gitignore
│ │ │ │ ├── app
│ │ │ │ │ ├── favicon.ico
│ │ │ │ │ ├── globals.css
│ │ │ │ │ ├── layout.tsx
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── components
│ │ │ │ │ ├── Flags.tsx
│ │ │ │ │ └── Providers.tsx
│ │ │ │ ├── next.config.mjs
│ │ │ │ ├── package.json
│ │ │ │ ├── postcss.config.mjs
│ │ │ │ ├── public
│ │ │ │ │ ├── next.svg
│ │ │ │ │ └── vercel.svg
│ │ │ │ ├── README.md
│ │ │ │ ├── tailwind.config.ts
│ │ │ │ └── tsconfig.json
│ │ │ └── plain
│ │ │ ├── app.tsx
│ │ │ ├── index.html
│ │ │ ├── index.tsx
│ │ │ ├── tsconfig.json
│ │ │ └── vite-env.d.ts
│ │ ├── eslint.config.js
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ └── index.tsx
│ │ ├── test
│ │ │ └── usage.test.tsx
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ ├── typedoc.json
│ │ └── vite.config.mjs
│ ├── tsconfig
│ │ ├── library.json
│ │ └── package.json
│ └── vue-sdk
│ ├── .prettierignore
│ ├── dev
│ │ └── plain
│ │ ├── App.vue
│ │ ├── components
│ │ │ ├── Events.vue
│ │ │ ├── FlagsList.vue
│ │ │ ├── MissingKeyMessage.vue
│ │ │ ├── RequestFeedback.vue
│ │ │ ├── Section.vue
│ │ │ ├── StartHuddlesButton.vue
│ │ │ └── Track.vue
│ │ ├── env.d.ts
│ │ ├── index.html
│ │ └── index.ts
│ ├── eslint.config.js
│ ├── package.json
│ ├── README.md
│ ├── src
│ │ ├── hooks.ts
│ │ ├── index.ts
│ │ ├── ReflagBootstrappedProvider.vue
│ │ ├── ReflagClientProvider.vue
│ │ ├── ReflagProvider.vue
│ │ ├── types.ts
│ │ ├── version.ts
│ │ └── vue.d.ts
│ ├── test
│ │ └── usage.test.ts
│ ├── tsconfig.build.json
│ ├── tsconfig.eslint.json
│ ├── tsconfig.json
│ ├── typedoc.json
│ └── vite.config.mjs
├── README.md
├── typedoc.json
├── vitest.workspace.js
└── yarn.lock
```
# Files
--------------------------------------------------------------------------------
/packages/node-sdk/examples/express/app.ts:
--------------------------------------------------------------------------------
```typescript
1 | import reflag from "./reflag";
2 | import express from "express";
3 | import { BoundReflagClient } from "../src";
4 |
5 | // Augment the Express types to include the `reflagUser` property on the `res.locals` object
6 | // This will allow us to access the ReflagClient instance in our route handlers
7 | // without having to pass it around manually
8 | declare global {
9 | namespace Express {
10 | interface Locals {
11 | reflagUser: BoundReflagClient;
12 | }
13 | }
14 | }
15 |
16 | const app = express();
17 |
18 | app.use(express.json());
19 | app.use((req, res, next) => {
20 | // Extract the user and company IDs from the request headers
21 | // You'll want to use a proper authentication and identification
22 | // mechanism in a real-world application
23 | const { user, company } = extractReflagContextFromHeader(req);
24 |
25 | // Create a new BoundReflagClient instance by calling the `bindClient` method on a `ReflagClient` instance
26 | // This will create a new instance that is bound to the user/company given.
27 | const reflagUser = reflag.bindClient({ user, company });
28 |
29 | // Store the BoundReflagClient instance in the `res.locals` object so we can access it in our route handlers
30 | res.locals.reflagUser = reflagUser;
31 | next();
32 | });
33 |
34 | export const todos = ["Buy milk", "Walk the dog"];
35 |
36 | app.get("/", (_req, res) => {
37 | res.locals.reflagUser.track("Front Page Viewed");
38 | res.json({ message: "Ready to manage some TODOs!" });
39 | });
40 |
41 | // Return todos if the feature is enabled for the user
42 | app.get("/todos", async (_req, res) => {
43 | // We use the `getFlag` method to check if the user has the "show-todos" feature enabled.
44 | // Note that "show-todos" is a feature that we defined in the `Flags` interface in the `reflag.ts` file.
45 | // and that the indexing for feature name below is type-checked at compile time.
46 | const { isEnabled, track } = res.locals.reflagUser.getFlag("show-todos");
47 |
48 | if (isEnabled) {
49 | track();
50 |
51 | // You can instead also send any custom event if you prefer, including attributes.
52 | // res.locals.reflagUser.track("Todo's viewed", { attributes: { access: "api" } });
53 |
54 | return res.json({ todos });
55 | }
56 |
57 | // Return no todos if the feature is disabled for the user
58 | return res.json({ todos: [] });
59 | });
60 |
61 | app.post("/todos", (req, res) => {
62 | const { todo } = req.body;
63 |
64 | if (typeof todo !== "string") {
65 | return res.status(400).json({ error: "Invalid todo" });
66 | }
67 |
68 | const { track, isEnabled, config } =
69 | res.locals.reflagUser.getFlag("create-todos");
70 |
71 | // Check if the user has the "create-todos" feature enabled
72 | if (isEnabled) {
73 | // Check if the todo is at least N characters long
74 | if (todo.length < config.payload.minimumLength) {
75 | return res
76 | .status(400)
77 | .json({ error: "Todo must be at least 5 characters long" });
78 | }
79 |
80 | // Track the feature usage
81 | track();
82 | todos.push(todo);
83 |
84 | return res.status(201).json({ todo });
85 | }
86 |
87 | res
88 | .status(403)
89 | .json({ error: "You do not have access to this feature yet!" });
90 | });
91 |
92 | app.delete("/todos/:idx", (req, res) => {
93 | const idx = parseInt(req.params.idx);
94 |
95 | if (isNaN(idx) || idx < 0 || idx >= todos.length) {
96 | return res.status(400).json({ error: "Invalid index" });
97 | }
98 |
99 | const { track, isEnabled } = res.locals.reflagUser.getFlag("delete-todos");
100 |
101 | if (isEnabled) {
102 | todos.splice(idx, 1);
103 |
104 | track();
105 | res.json({});
106 | }
107 |
108 | res
109 | .status(403)
110 | .json({ error: "You do not have access to this feature yet!" });
111 | });
112 |
113 | app.get("/features", async (_req, res) => {
114 | const features = await res.locals.reflagUser.getFlagsRemote();
115 | res.json(features);
116 | });
117 |
118 | export default app;
119 |
120 | function extractReflagContextFromHeader(req: express.Request) {
121 | const user = req.headers["x-reflag-user-id"]
122 | ? {
123 | id: req.headers["x-reflag-user-id"] as string,
124 | role: req.headers["x-reflag-is-admin"] ? "admin" : "user",
125 | }
126 | : undefined;
127 | const company = req.headers["x-reflag-company-id"]
128 | ? {
129 | id: req.headers["x-reflag-company-id"] as string,
130 | betaUser: !!req.headers["x-reflag-company-beta-user"],
131 | }
132 | : undefined;
133 | return { user, company };
134 | }
135 |
```
--------------------------------------------------------------------------------
/packages/cli/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | #!/usr/bin/env node
2 | import chalk from "chalk";
3 | import { program } from "commander";
4 | import ora from "ora";
5 |
6 | import { registerAppCommands } from "./commands/apps.js";
7 | import { registerAuthCommands } from "./commands/auth.js";
8 | import { registerFlagCommands } from "./commands/flags.js";
9 | import { registerInitCommand } from "./commands/init.js";
10 | import { registerMcpCommand } from "./commands/mcp.js";
11 | import { registerNewCommand } from "./commands/new.js";
12 | import { registerRulesCommand } from "./commands/rules.js";
13 | import { bootstrap, getReflagUser } from "./services/bootstrap.js";
14 | import { authStore } from "./stores/auth.js";
15 | import { configStore } from "./stores/config.js";
16 | import { commandName } from "./utils/commander.js";
17 | import { handleError } from "./utils/errors.js";
18 | import {
19 | apiKeyOption,
20 | apiUrlOption,
21 | baseUrlOption,
22 | debugOption,
23 | } from "./utils/options.js";
24 | import { stripTrailingSlash } from "./utils/urls.js";
25 | import { checkLatest as checkLatestVersion } from "./utils/version.js";
26 |
27 | const skipBootstrapCommands = [/^login/, /^logout/, /^rules/];
28 |
29 | type Options = {
30 | debug?: boolean;
31 | baseUrl?: string;
32 | apiUrl?: string;
33 | apiKey?: string;
34 | };
35 |
36 | async function main() {
37 | // Start a version check in the background
38 | // unhandled promise rejection can happen even without the `await`
39 | // so we need a `catch` here.
40 | const cliVersionCheckPromise = checkLatestVersion().catch(() => ({
41 | latestVersion: "unknown",
42 | currentVersion: "unknown",
43 | isNewerAvailable: false,
44 | }));
45 |
46 | // Must load tokens and config before anything else
47 | await authStore.initialize();
48 | await configStore.initialize();
49 |
50 | // Global options
51 | program.addOption(debugOption);
52 | program.addOption(baseUrlOption);
53 | program.addOption(apiUrlOption);
54 | program.addOption(apiKeyOption);
55 |
56 | // Pre-action hook
57 | program.hook("preAction", async (_, actionCommand) => {
58 | const {
59 | debug,
60 | baseUrl,
61 | apiUrl,
62 | apiKey: explicitApiKey,
63 | } = program.opts<Options>();
64 | const cleanedBaseUrl = stripTrailingSlash(baseUrl?.trim());
65 | const cleanedApiUrl = stripTrailingSlash(apiUrl?.trim());
66 |
67 | const apiKey = explicitApiKey ?? process.env.REFLAG_API_KEY;
68 |
69 | if (typeof apiKey === "string" && apiKey.length > 0) {
70 | console.info(
71 | chalk.yellow(
72 | "API key supplied. Using it instead of normal personal authentication.",
73 | ),
74 | );
75 | authStore.useApiKey(apiKey);
76 | }
77 |
78 | // Set baseUrl and apiUrl in config store, will skip if undefined
79 | configStore.setConfig({
80 | baseUrl: cleanedBaseUrl,
81 | apiUrl: cleanedApiUrl || (cleanedBaseUrl && `${cleanedBaseUrl}/api`),
82 | });
83 |
84 | // Skip bootstrapping for commands that don't require it
85 | if (
86 | !skipBootstrapCommands.some((cmd) => cmd.test(commandName(actionCommand)))
87 | ) {
88 | const spinner = ora("Bootstrapping...").start();
89 |
90 | try {
91 | // Load bootstrap data if not already loaded
92 | await bootstrap();
93 | spinner.stop();
94 | } catch (error) {
95 | spinner.fail("Bootstrap failed.");
96 | handleError(error, "Connect");
97 | }
98 | }
99 |
100 | const { latestVersion, currentVersion, isNewerAvailable } =
101 | await cliVersionCheckPromise;
102 |
103 | if (isNewerAvailable) {
104 | console.info(
105 | `A new version of the CLI is available: ${chalk.yellow(
106 | currentVersion,
107 | )} -> ${chalk.green(latestVersion)}. Update to ensure you have the latest features and bug fixes.`,
108 | );
109 | }
110 |
111 | if (debug) {
112 | console.debug(chalk.cyan("\nDebug mode enabled."));
113 | const user = getReflagUser();
114 | console.debug(`Logged in as ${chalk.cyan(user.name ?? user.email)}.`);
115 | console.debug(
116 | "Reading config from:",
117 | chalk.cyan(configStore.getConfigPath()),
118 | );
119 | console.table(configStore.getConfig());
120 | }
121 | });
122 |
123 | // Main program
124 | registerNewCommand(program);
125 | registerInitCommand(program);
126 | registerAuthCommands(program);
127 | registerAppCommands(program);
128 | registerFlagCommands(program);
129 | registerMcpCommand(program);
130 | registerRulesCommand(program);
131 |
132 | program.parse(process.argv);
133 | }
134 |
135 | void main();
136 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/test/hooksManager.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { beforeEach, describe, expect, it, vi } from "vitest";
2 |
3 | import { CompanyContext, UserContext } from "../src";
4 | import { CheckEvent, RawFlags } from "../src/flag/flags";
5 | import { HooksManager } from "../src/hooksManager";
6 |
7 | describe("HookManager", () => {
8 | let hookManager: HooksManager;
9 |
10 | beforeEach(() => {
11 | hookManager = new HooksManager();
12 | });
13 |
14 | it("should add and trigger `check` hooks (is-enabled)", () => {
15 | const callback = vi.fn();
16 | hookManager.addHook("check", callback);
17 |
18 | const checkEvent: CheckEvent = {
19 | action: "check-is-enabled",
20 | key: "test-key",
21 | value: true,
22 | };
23 | hookManager.trigger("check", checkEvent);
24 |
25 | expect(callback).toHaveBeenCalledWith(checkEvent);
26 | });
27 |
28 | it("should add and trigger `check` hooks (config)", () => {
29 | const callback = vi.fn();
30 | hookManager.addHook("check", callback);
31 |
32 | const checkEvent: CheckEvent = {
33 | action: "check-config",
34 | key: "test-key",
35 | value: { key: "key", payload: "payload" },
36 | };
37 | hookManager.trigger("check", checkEvent);
38 |
39 | expect(callback).toHaveBeenCalledWith(checkEvent);
40 | });
41 |
42 | it("should add and trigger `flagsUpdated` hooks", () => {
43 | const callback = vi.fn();
44 | hookManager.addHook("flagsUpdated", callback);
45 |
46 | const flags: RawFlags = {
47 | /* mock RawFlags data */
48 | };
49 | hookManager.trigger("flagsUpdated", flags);
50 |
51 | expect(callback).toHaveBeenCalledWith(flags);
52 | });
53 |
54 | it("should add and trigger `track` hooks", () => {
55 | const callback = vi.fn();
56 | const user: UserContext = { id: "user-id", name: "user-name" };
57 | const company: CompanyContext = { id: "company-id", name: "company-name" };
58 | hookManager.addHook("track", callback);
59 |
60 | const eventName = "test-event";
61 | const attributes = { key: "value" };
62 | hookManager.trigger("track", { eventName, attributes, user, company });
63 |
64 | expect(callback).toHaveBeenCalledWith({
65 | eventName,
66 | attributes,
67 | user,
68 | company,
69 | });
70 | });
71 |
72 | it("should add and trigger `user` hooks", () => {
73 | const callback = vi.fn();
74 |
75 | hookManager.addHook("user", callback);
76 |
77 | const user = { id: "user-id", name: "user-name" };
78 | hookManager.trigger("user", user);
79 |
80 | expect(callback).toHaveBeenCalledWith(user);
81 | });
82 |
83 | it("should add and trigger `company` hooks", () => {
84 | const callback = vi.fn();
85 | hookManager.addHook("company", callback);
86 |
87 | const company = { id: "company-id", name: "company-name" };
88 | hookManager.trigger("company", company);
89 |
90 | expect(callback).toHaveBeenCalledWith(company);
91 | });
92 |
93 | it("should handle multiple hooks of the same type", () => {
94 | const callback1 = vi.fn();
95 | const callback2 = vi.fn();
96 |
97 | hookManager.addHook("check", callback1);
98 | hookManager.addHook("check", callback2);
99 |
100 | const checkEvent: CheckEvent = {
101 | action: "check-is-enabled",
102 | key: "test-key",
103 | value: true,
104 | };
105 | hookManager.trigger("check", checkEvent);
106 |
107 | expect(callback1).toHaveBeenCalledWith(checkEvent);
108 | expect(callback2).toHaveBeenCalledWith(checkEvent);
109 | });
110 |
111 | it("should remove the given hook and no other hooks", () => {
112 | const callback1 = vi.fn();
113 | const callback2 = vi.fn();
114 |
115 | hookManager.addHook("check", callback1);
116 | hookManager.addHook("check", callback2);
117 | hookManager.removeHook("check", callback1);
118 |
119 | const checkEvent: CheckEvent = {
120 | action: "check-is-enabled",
121 | key: "test-key",
122 | value: true,
123 | };
124 | hookManager.trigger("check", checkEvent);
125 |
126 | expect(callback1).not.toHaveBeenCalled();
127 | expect(callback2).toHaveBeenCalledWith(checkEvent);
128 | });
129 |
130 | it("should remove the hook using the function returned from addHook", () => {
131 | const callback1 = vi.fn();
132 | const callback2 = vi.fn();
133 |
134 | const removeHook1 = hookManager.addHook("check", callback1);
135 | hookManager.addHook("check", callback2);
136 | removeHook1();
137 |
138 | const checkEvent: CheckEvent = {
139 | action: "check-is-enabled",
140 | key: "test-key",
141 | value: true,
142 | };
143 | hookManager.trigger("check", checkEvent);
144 |
145 | expect(callback1).not.toHaveBeenCalled();
146 | expect(callback2).toHaveBeenCalledWith(checkEvent);
147 | });
148 | });
149 |
```
--------------------------------------------------------------------------------
/packages/node-sdk/test/fetch-http-client.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { afterEach, describe, expect, it, vi } from "vitest";
2 |
3 | import { API_TIMEOUT_MS } from "../src/config";
4 | import fetchClient from "../src/fetch-http-client";
5 |
6 | // mock environment variables
7 | vi.mock("../src/config", () => ({ API_TIMEOUT_MS: 100 }));
8 |
9 | describe("fetchClient", () => {
10 | const url = "https://example.com/api";
11 | const headers = { "Content-Type": "application/json" };
12 |
13 | afterEach(() => {
14 | vi.resetAllMocks();
15 | });
16 |
17 | it("should make a POST request and return the response", async () => {
18 | const body = { key: "value" };
19 | const response = { ok: true, status: 200, body: { success: true } };
20 |
21 | global.fetch = vi.fn().mockResolvedValue({
22 | ok: true,
23 | status: 200,
24 | json: async () => ({
25 | success: true,
26 | }),
27 | } as Response);
28 |
29 | const result = await fetchClient.post<typeof body, typeof response>(
30 | url,
31 | headers,
32 | body,
33 | );
34 |
35 | expect(result).toEqual(response);
36 | expect(global.fetch).toHaveBeenCalledTimes(1);
37 | expect(global.fetch).toHaveBeenCalledWith(
38 | url,
39 | expect.objectContaining({
40 | method: "post",
41 | headers,
42 | body: JSON.stringify(body),
43 | signal: expect.any(AbortSignal),
44 | }),
45 | );
46 | });
47 |
48 | it("should make a GET request and return the response", async () => {
49 | const response = { ok: true, status: 200, body: { success: true } };
50 |
51 | global.fetch = vi.fn().mockResolvedValue({
52 | ok: true,
53 | status: 200,
54 | json: async () => ({
55 | success: true,
56 | }),
57 | } as Response);
58 |
59 | const result = await fetchClient.get<typeof response>(url, headers);
60 |
61 | expect(result).toEqual(response);
62 | expect(global.fetch).toHaveBeenCalledTimes(1);
63 | expect(global.fetch).toHaveBeenCalledWith(
64 | url,
65 | expect.objectContaining({
66 | method: "get",
67 | headers,
68 | signal: expect.any(AbortSignal),
69 | }),
70 | );
71 | });
72 |
73 | it("should timeout a POST request that takes too long", async () => {
74 | global.fetch = vi
75 | .fn()
76 | .mockImplementation(
77 | () =>
78 | new Promise((resolve) =>
79 | setTimeout(
80 | () => resolve({ ok: true, json: async () => ({}) }),
81 | API_TIMEOUT_MS + 100,
82 | ),
83 | ),
84 | );
85 |
86 | await fetchClient.post(url, headers, {});
87 | expect(vi.mocked(global.fetch).mock.calls[0][1]?.signal?.aborted).toBe(
88 | true,
89 | );
90 | });
91 |
92 | it("should timeout a GET request that takes too long", async () => {
93 | global.fetch = vi
94 | .fn()
95 | .mockImplementation(
96 | () =>
97 | new Promise((resolve) =>
98 | setTimeout(
99 | () => resolve({ ok: true, json: async () => ({}) }),
100 | API_TIMEOUT_MS + 100,
101 | ),
102 | ),
103 | );
104 |
105 | await fetchClient.get(url, headers);
106 | expect(vi.mocked(global.fetch).mock.calls[0][1]?.signal?.aborted).toBe(
107 | true,
108 | );
109 | });
110 |
111 | it("should handle POST non-20x responses", async () => {
112 | const response = {
113 | ok: false,
114 | status: 400,
115 | body: { error: "Something went wrong" },
116 | };
117 |
118 | global.fetch = vi.fn().mockResolvedValue({
119 | ok: false,
120 | status: 400,
121 | json: async () => ({ error: "Something went wrong" }),
122 | } as Response);
123 |
124 | const result = await fetchClient.post(url, headers, {});
125 |
126 | expect(result).toEqual(response);
127 | });
128 |
129 | it("should handle GET non-20x responses", async () => {
130 | const response = {
131 | ok: false,
132 | status: 400,
133 | body: { error: "Something went wrong" },
134 | };
135 |
136 | global.fetch = vi.fn().mockResolvedValue({
137 | ok: false,
138 | status: 400,
139 | json: async () => ({ error: "Something went wrong" }),
140 | } as Response);
141 |
142 | const result = await fetchClient.get(url, headers);
143 |
144 | expect(result).toEqual(response);
145 | });
146 |
147 | it("should not handle POST exceptions", async () => {
148 | global.fetch = vi.fn().mockRejectedValue(new Error("Network error"));
149 |
150 | await expect(fetchClient.post(url, headers, {})).rejects.toThrow(
151 | "Network error",
152 | );
153 | });
154 |
155 | it("should not handle GET exceptions", async () => {
156 | global.fetch = vi.fn().mockRejectedValue(new Error("Network error"));
157 |
158 | await expect(fetchClient.get(url, headers)).rejects.toThrow(
159 | "Network error",
160 | );
161 | });
162 | });
163 |
```
--------------------------------------------------------------------------------
/packages/cli/utils/gen.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { camelCase, kebabCase, pascalCase, snakeCase } from "change-case";
2 | import { mkdir, writeFile } from "node:fs/promises";
3 | import { dirname, isAbsolute, join } from "node:path";
4 |
5 | import { Flag, RemoteConfig } from "../services/flags.js";
6 |
7 | import { JSONToType, quoteKey } from "./json.js";
8 |
9 | export type GenFormat = "react" | "node";
10 |
11 | // Keep in sync with Reflag main repo
12 | export const KeyFormats = [
13 | "custom",
14 | "pascalCase",
15 | "camelCase",
16 | "snakeCaseUpper",
17 | "snakeCaseLower",
18 | "kebabCaseUpper",
19 | "kebabCaseLower",
20 | ] as const;
21 |
22 | export type KeyFormat = (typeof KeyFormats)[number];
23 |
24 | type KeyFormatPattern = {
25 | transform: (key: string) => string;
26 | regex: RegExp;
27 | message: string;
28 | };
29 |
30 | export const KeyFormatPatterns: Record<KeyFormat, KeyFormatPattern> = {
31 | custom: {
32 | transform: (key) => key?.trim(),
33 | regex: /^[\p{L}\p{N}\p{P}\p{S}\p{Z}]+$/u,
34 | message:
35 | "Key must contain only letters, numbers, punctuation, symbols, or spaces.",
36 | },
37 | pascalCase: {
38 | transform: (key) => pascalCase(key),
39 | regex: /^[\p{Lu}][\p{L}\p{N}]*$/u,
40 | message:
41 | "Key must start with uppercase letter and contain only letters and numbers.",
42 | },
43 | camelCase: {
44 | transform: (key) => camelCase(key),
45 | regex: /^[\p{Ll}][\p{L}\p{N}]*$/u,
46 | message:
47 | "Key must start with lowercase letter and contain only letters and numbers.",
48 | },
49 | snakeCaseUpper: {
50 | transform: (key) => snakeCase(key).toUpperCase(),
51 | regex: /^[\p{Lu}][\p{Lu}\p{N}]*(?:_[\p{Lu}\p{N}]+)*$/u,
52 | message: "Key must be uppercase with words separated by underscores.",
53 | },
54 | snakeCaseLower: {
55 | transform: (key) => snakeCase(key).toLowerCase(),
56 | regex: /^[\p{Ll}][\p{Ll}\p{N}]*(?:_[\p{Ll}\p{N}]+)*$/u,
57 | message: "Key must be lowercase with words separated by underscores.",
58 | },
59 | kebabCaseUpper: {
60 | transform: (key) => kebabCase(key).toUpperCase(),
61 | regex: /^[\p{Lu}][\p{Lu}\p{N}]*(?:-[\p{Lu}\p{N}]+)*$/u,
62 | message: "Key must be uppercase with words separated by hyphens.",
63 | },
64 | kebabCaseLower: {
65 | transform: (key) => kebabCase(key).toLowerCase(),
66 | regex: /^[\p{Ll}][\p{Ll}\p{N}]*(?:-[\p{Ll}\p{N}]+)*$/u,
67 | message: "Key must be lowercase with words separated by hyphens.",
68 | },
69 | };
70 |
71 | export function indentLines(
72 | str: string,
73 | indent = 2,
74 | lineBreak = "\n",
75 | trim = false,
76 | ): string {
77 | const indentStr = " ".repeat(indent);
78 | return str
79 | .split(lineBreak)
80 | .map((line) => `${indentStr}${trim ? line.trim() : line}`)
81 | .join(lineBreak);
82 | }
83 |
84 | export function genFlagKey(input: string, format: KeyFormat): string {
85 | return KeyFormatPatterns[format].transform(input);
86 | }
87 |
88 | export function genRemoteConfig(remoteConfigs?: RemoteConfig[]) {
89 | const variants = remoteConfigs?.[0]?.variants;
90 | if (!variants?.length) return;
91 | return JSONToType(
92 | remoteConfigs![0].variants?.map(({ variant: { payload } }) => payload),
93 | );
94 | }
95 |
96 | export function genTypes(flags: Flag[], format: GenFormat = "react") {
97 | const configDefs = new Map<string, { name: string; definition: string }>();
98 | flags.forEach(({ key, name, remoteConfigs }) => {
99 | const definition = genRemoteConfig(remoteConfigs);
100 |
101 | if (!definition) {
102 | return;
103 | }
104 |
105 | const configName = `${pascalCase(name)}ConfigPayload`;
106 | configDefs.set(key, { name: configName, definition });
107 | });
108 |
109 | return /* ts */ `
110 | // DO NOT EDIT THIS FILE. IT IS GENERATED BY THE REFLAG CLI AND WILL BE OVERWRITTEN.
111 | // eslint-disable
112 | // prettier-ignore
113 | import "@reflag/${format}-sdk";
114 |
115 | declare module "@reflag/${format}-sdk" {
116 | export interface Flags {
117 | ${flags
118 | .map(({ key }) => {
119 | const config = configDefs.get(key);
120 | return indentLines(
121 | `${quoteKey(key)}: ${config?.definition ? `{ config: { payload: ${config.name} } }` : "boolean"};`,
122 | 4,
123 | );
124 | })
125 | .join("\n")}
126 | }
127 |
128 | ${Array.from(configDefs.values())
129 | .map(({ name, definition }) => {
130 | return indentLines(`export type ${name} = ${definition}`);
131 | })
132 | .join("\n\n")}
133 | }
134 | `.trim();
135 | }
136 |
137 | export async function writeTypesToFile(
138 | types: string,
139 | outPath: string,
140 | projectPath: string,
141 | ) {
142 | const fullPath = isAbsolute(outPath) ? outPath : join(projectPath, outPath);
143 |
144 | await mkdir(dirname(fullPath), { recursive: true });
145 | await writeFile(fullPath, types);
146 |
147 | return fullPath;
148 | }
149 |
```
--------------------------------------------------------------------------------
/packages/node-sdk/src/config.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { readFileSync } from "fs";
2 |
3 | import { version } from "../package.json";
4 |
5 | import { LOG_LEVELS } from "./types";
6 | import { isObject, ok } from "./utils";
7 |
8 | export const API_BASE_URL = "https://front.reflag.com";
9 | export const SDK_VERSION_HEADER_NAME = "reflag-sdk-version";
10 | export const SDK_VERSION = `node-sdk/${version}`;
11 | export const API_TIMEOUT_MS = 10000;
12 | export const END_FLUSH_TIMEOUT_MS = 5000;
13 |
14 | export const REFLAG_LOG_PREFIX = "[Reflag]";
15 |
16 | export const FLAG_EVENT_RATE_LIMITER_WINDOW_SIZE_MS = 60 * 1000;
17 |
18 | export const FLAGS_REFETCH_MS = 60 * 1000; // re-fetch every 60 seconds
19 |
20 | export const BATCH_MAX_SIZE = 100;
21 | export const BATCH_INTERVAL_MS = 10 * 1000;
22 |
23 | function parseOverrides(config: object | undefined) {
24 | if (!config) return {};
25 | if ("flagOverrides" in config && isObject(config.flagOverrides)) {
26 | Object.entries(config.flagOverrides).forEach(([key, value]) => {
27 | ok(
28 | typeof value === "boolean" || isObject(value),
29 | `invalid type "${typeof value}" for key ${key}, expected boolean or object`,
30 | );
31 | if (isObject(value)) {
32 | ok(
33 | "isEnabled" in value && typeof value.isEnabled === "boolean",
34 | `invalid type "${typeof value.isEnabled}" for key ${key}.isEnabled, expected boolean`,
35 | );
36 | ok(
37 | value.config === undefined || isObject(value.config),
38 | `invalid type "${typeof value.config}" for key ${key}.config, expected object or undefined`,
39 | );
40 | if (isObject(value.config)) {
41 | ok(
42 | "key" in value.config && typeof value.config.key === "string",
43 | `invalid type "${typeof value.config.key}" for key ${key}.config.key, expected string`,
44 | );
45 | }
46 | }
47 | });
48 |
49 | return config.flagOverrides;
50 | }
51 |
52 | return {};
53 | }
54 |
55 | function loadConfigFile(file: string) {
56 | const configJson = readFileSync(file, "utf-8");
57 | const config = JSON.parse(configJson);
58 |
59 | ok(typeof config === "object", "config must be an object");
60 | const { secretKey, logLevel, offline, host, apiBaseUrl } = config;
61 |
62 | ok(
63 | typeof secretKey === "undefined" || typeof secretKey === "string",
64 | "secret must be a string",
65 | );
66 | ok(
67 | typeof apiBaseUrl === "undefined" || typeof apiBaseUrl === "string",
68 | "apiBaseUrl must be a string",
69 | );
70 | ok(
71 | typeof logLevel === "undefined" ||
72 | (typeof logLevel === "string" && LOG_LEVELS.includes(logLevel as any)),
73 | `logLevel must one of ${LOG_LEVELS.join(", ")}`,
74 | );
75 | ok(
76 | typeof offline === "undefined" || typeof offline === "boolean",
77 | "offline must be a boolean",
78 | );
79 |
80 | return {
81 | flagOverrides: parseOverrides(config),
82 | secretKey,
83 | logLevel,
84 | offline,
85 | apiBaseUrl: host ?? apiBaseUrl,
86 | };
87 | }
88 |
89 | function loadEnvVars() {
90 | const secretKey = process.env.REFLAG_SECRET_KEY;
91 | const enabledFlags = process.env.REFLAG_FLAGS_ENABLED;
92 | const disabledFlags = process.env.REFLAG_FLAGS_DISABLED;
93 | const logLevel = process.env.REFLAG_LOG_LEVEL;
94 | const apiBaseUrl = process.env.REFLAG_API_BASE_URL;
95 | const offline =
96 | process.env.REFLAG_OFFLINE !== undefined
97 | ? ["true", "on"].includes(process.env.REFLAG_OFFLINE)
98 | : undefined;
99 |
100 | let flagOverrides: Record<string, boolean> = {};
101 | if (enabledFlags) {
102 | flagOverrides = enabledFlags.split(",").reduce(
103 | (acc, f) => {
104 | const key = f.trim();
105 | if (key) acc[key] = true;
106 | return acc;
107 | },
108 | {} as Record<string, boolean>,
109 | );
110 | }
111 |
112 | if (disabledFlags) {
113 | flagOverrides = {
114 | ...flagOverrides,
115 | ...disabledFlags.split(",").reduce(
116 | (acc, f) => {
117 | const key = f.trim();
118 | if (key) acc[key] = false;
119 | return acc;
120 | },
121 | {} as Record<string, boolean>,
122 | ),
123 | };
124 | }
125 |
126 | return { secretKey, flagOverrides, logLevel, offline, apiBaseUrl };
127 | }
128 |
129 | export function loadConfig(file?: string) {
130 | let fileConfig;
131 | if (file) {
132 | fileConfig = loadConfigFile(file);
133 | }
134 |
135 | const envConfig = loadEnvVars();
136 |
137 | return {
138 | secretKey: envConfig.secretKey || fileConfig?.secretKey,
139 | logLevel: envConfig.logLevel || fileConfig?.logLevel,
140 | offline: envConfig.offline ?? fileConfig?.offline,
141 | apiBaseUrl: envConfig.apiBaseUrl ?? fileConfig?.apiBaseUrl,
142 | flagOverrides: {
143 | ...fileConfig?.flagOverrides,
144 | ...envConfig.flagOverrides,
145 | },
146 | };
147 | }
148 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/src/toolbar/Flags.tsx:
--------------------------------------------------------------------------------
```typescript
1 | import { Fragment, h } from "preact";
2 |
3 | import { Switch } from "./Switch";
4 | import { FlagItem } from "./Toolbar";
5 |
6 | const isFound = (flagKey: string, searchQuery: string | null) => {
7 | return flagKey.toLocaleLowerCase().includes(searchQuery ?? "");
8 | };
9 |
10 | export function FlagsTable({
11 | flags,
12 | searchQuery,
13 | appBaseUrl,
14 | setIsEnabledOverride,
15 | }: {
16 | flags: FlagItem[];
17 | searchQuery: string | null;
18 | appBaseUrl: string;
19 | setIsEnabledOverride: (key: string, isEnabled: boolean | null) => void;
20 | }) {
21 | const hasFlags = flags.length > 0;
22 | const hasShownFlags = flags.some((flag) =>
23 | isFound(flag.flagKey, searchQuery),
24 | );
25 |
26 | // List flags that match the search query first then alphabetically
27 | const searchedFlags =
28 | searchQuery === null
29 | ? flags
30 | : [...flags].sort((a, b) => {
31 | const aMatches = isFound(a.flagKey, searchQuery);
32 | const bMatches = isFound(b.flagKey, searchQuery);
33 |
34 | // If both match or both don't match, sort alphabetically
35 | if (aMatches === bMatches) {
36 | const aStartsWith = a.flagKey
37 | .toLocaleLowerCase()
38 | .startsWith(searchQuery);
39 | const bStartsWith = b.flagKey
40 | .toLocaleLowerCase()
41 | .startsWith(searchQuery);
42 |
43 | // If one starts with search query and the other doesn't, prioritize the one that starts with it
44 | if (aStartsWith && !bStartsWith) return -1;
45 | if (bStartsWith && !aStartsWith) return 1;
46 |
47 | // Otherwise sort alphabetically
48 | return a.flagKey.localeCompare(b.flagKey);
49 | }
50 |
51 | // Otherwise, matching flags come first
52 | return aMatches ? -1 : 1;
53 | });
54 |
55 | return (
56 | <Fragment>
57 | {(!hasFlags || !hasShownFlags) && (
58 | <div class="flags-table-empty">
59 | No flags {hasFlags ? `matching "${searchQuery}"` : "found"}
60 | </div>
61 | )}
62 | <table class="flags-table">
63 | <tbody>
64 | {searchedFlags.map((flag, index) => (
65 | <FlagRow
66 | key={flag.flagKey}
67 | appBaseUrl={appBaseUrl}
68 | flag={flag}
69 | index={index}
70 | isNotVisible={
71 | searchQuery !== null && !isFound(flag.flagKey, searchQuery)
72 | }
73 | setEnabledOverride={(override) =>
74 | setIsEnabledOverride(flag.flagKey, override)
75 | }
76 | />
77 | ))}
78 | </tbody>
79 | </table>
80 | </Fragment>
81 | );
82 | }
83 |
84 | function FlagRow({
85 | setEnabledOverride,
86 | appBaseUrl,
87 | flag,
88 | index,
89 | isNotVisible,
90 | }: {
91 | flag: FlagItem;
92 | appBaseUrl: string;
93 | setEnabledOverride: (isEnabled: boolean | null) => void;
94 | index: number;
95 | isNotVisible: boolean;
96 | }) {
97 | const isEnabledOverride = flag.isEnabledOverride !== null;
98 | return (
99 | <tr
100 | key={flag.flagKey}
101 | class={["flag-row", isNotVisible ? "not-visible" : undefined].join(" ")}
102 | >
103 | <td class="flag-name-cell">
104 | <a
105 | class="flag-link"
106 | href={`${appBaseUrl}/env-current/flags/by-key/${flag.flagKey}`}
107 | rel="noreferrer"
108 | tabIndex={index + 1}
109 | target="_blank"
110 | >
111 | {flag.flagKey}
112 | </a>
113 | </td>
114 | <td class="flag-reset-cell">
115 | {isEnabledOverride ? (
116 | <Reset setEnabledOverride={setEnabledOverride} tabIndex={index + 1} />
117 | ) : null}
118 | </td>
119 | <td class="flag-switch-cell">
120 | <Switch
121 | checked={flag.isEnabledOverride ?? flag.isEnabled}
122 | tabIndex={index + 1}
123 | onChange={(e) => {
124 | const isChecked = e.currentTarget.checked;
125 | setEnabledOverride(!isEnabledOverride ? isChecked : null);
126 | }}
127 | />
128 | </td>
129 | </tr>
130 | );
131 | }
132 |
133 | export function FlagSearch({ onSearch }: { onSearch: (val: string) => void }) {
134 | return (
135 | <input
136 | class="search-input"
137 | placeholder="Search flags"
138 | tabIndex={0}
139 | type="search"
140 | autoFocus
141 | onInput={(s) => onSearch(s.currentTarget.value)}
142 | />
143 | );
144 | }
145 |
146 | function Reset({
147 | setEnabledOverride,
148 | ...props
149 | }: {
150 | setEnabledOverride: (isEnabled: boolean | null) => void;
151 | } & h.JSX.HTMLAttributes<HTMLAnchorElement>) {
152 | return (
153 | <a
154 | class="reset"
155 | href=""
156 | onClick={(e) => {
157 | e.preventDefault();
158 | setEnabledOverride(null);
159 | }}
160 | {...props}
161 | >
162 | reset
163 | </a>
164 | );
165 | }
166 |
```
--------------------------------------------------------------------------------
/packages/node-sdk/test/periodicallyUpdatingCache.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/periodicallyUpdatingCache";
13 | import { Logger } from "../src/types";
14 |
15 | describe("cache", () => {
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, 2000, logger, fn);
39 |
40 | const result = await cached.refresh();
41 |
42 | expect(result).toBe(42);
43 | expect(logger.debug).toHaveBeenCalledWith(
44 | expect.stringMatching("updated cached value"),
45 | 42,
46 | );
47 | });
48 |
49 | it("should not allow multiple refreses at the same time", async () => {
50 | const cached = cache(1000, 2000, 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("updated cached 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("updated cached value"),
71 | 42,
72 | );
73 | });
74 |
75 | it("should warn if the cached value is stale", async () => {
76 | const cached = cache(1000, 2000, logger, fn);
77 |
78 | await cached.refresh();
79 |
80 | vi.advanceTimersByTime(2500);
81 |
82 | const result = cached.get();
83 |
84 | expect(result).toBe(42);
85 | expect(logger.warn).toHaveBeenCalledWith(
86 | expect.stringMatching("cached value is stale"),
87 | {
88 | age: expect.any(Number),
89 | cachedValue: 42,
90 | },
91 | );
92 | });
93 |
94 | it("should update the cached value after ttl", async () => {
95 | const newValue = 84;
96 | fn = vi.fn().mockResolvedValueOnce(42).mockResolvedValueOnce(newValue);
97 |
98 | const cached = cache(1000, 2000, logger, fn);
99 |
100 | const first = await cached.refresh();
101 |
102 | expect(first).toBe(42);
103 | expect(fn).toHaveBeenCalledTimes(1);
104 |
105 | await vi.advanceTimersToNextTimerAsync();
106 |
107 | const second = cached.get();
108 |
109 | expect(second).toBe(newValue);
110 | expect(fn).toHaveBeenCalledTimes(2);
111 |
112 | expect(logger.debug).toHaveBeenCalledWith(
113 | expect.stringMatching("updated cached value"),
114 | newValue,
115 | );
116 | });
117 |
118 | it("should handle update failures gracefully", async () => {
119 | const error = new Error("update failed");
120 | fn = vi.fn().mockRejectedValueOnce(error).mockResolvedValueOnce(42);
121 |
122 | const cached = cache(1000, 2000, logger, fn);
123 |
124 | const first = await cached.refresh();
125 |
126 | expect(first).toBeUndefined();
127 | expect(logger.error).toHaveBeenCalledWith(
128 | expect.stringMatching("failed to update cached value"),
129 | error,
130 | );
131 | expect(fn).toHaveBeenCalledTimes(1);
132 |
133 | await vi.advanceTimersToNextTimerAsync();
134 |
135 | expect(fn).toHaveBeenCalledTimes(2);
136 | expect(logger.debug).toHaveBeenCalledWith(
137 | expect.stringMatching("updated cached value"),
138 | 42,
139 | );
140 |
141 | const second = cached.get();
142 | expect(second).toBe(42);
143 | });
144 |
145 | it("should retain the cached value if the new value is undefined", async () => {
146 | fn = vi.fn().mockResolvedValueOnce(42).mockResolvedValueOnce(undefined);
147 | const cached = cache(1000, 2000, logger, fn);
148 |
149 | await cached.refresh();
150 |
151 | await vi.advanceTimersToNextTimerAsync();
152 |
153 | const second = cached.get();
154 | expect(second).toBe(42);
155 |
156 | vi.advanceTimersByTime(2500);
157 |
158 | const result = cached.get();
159 |
160 | expect(result).toBe(42);
161 | expect(logger.warn).toHaveBeenCalledWith(
162 | expect.stringMatching("cached value is stale"),
163 | {
164 | age: expect.any(Number),
165 | cachedValue: 42,
166 | },
167 | );
168 | });
169 |
170 | it("should not update if cached value is still valid", async () => {
171 | const cached = cache(1000, 2000, logger, fn);
172 |
173 | const first = await cached.refresh();
174 |
175 | vi.advanceTimersByTime(500);
176 |
177 | const second = cached.get();
178 |
179 | expect(first).toBe(second);
180 | expect(logger.debug).toHaveBeenCalledTimes(1); // Only one update call
181 | });
182 |
183 | afterEach(() => {
184 | vi.clearAllTimers();
185 | vi.restoreAllMocks();
186 | });
187 | });
188 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/src/toolbar/Toolbar.css:
--------------------------------------------------------------------------------
```css
1 | /* Animations */
2 |
3 | @keyframes bounceInUp {
4 | from,
5 | 60%,
6 | 75%,
7 | 90%,
8 | to {
9 | animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);
10 | }
11 |
12 | from {
13 | opacity: 0;
14 | transform: translate3d(0, 50px, 0) scaleY(2);
15 | }
16 |
17 | 60% {
18 | opacity: 1;
19 | transform: translate3d(0, -6px, 0) scaleY(0.9);
20 | }
21 |
22 | 75% {
23 | transform: translate3d(0, 3px, 0) scaleY(0.95);
24 | }
25 |
26 | 90% {
27 | transform: translate3d(0, -2px, 0) scaleY(0.985);
28 | }
29 |
30 | to {
31 | transform: translate3d(0, 0, 0);
32 | }
33 | }
34 |
35 | @keyframes gelatine {
36 | from,
37 | to {
38 | transform: scale(1, 1);
39 | }
40 | 25% {
41 | transform: scale(0.9, 1.1);
42 | }
43 | 50% {
44 | transform: scale(1.1, 0.9);
45 | }
46 | 75% {
47 | transform: scale(0.95, 1.05);
48 | }
49 | }
50 |
51 | /* Toolbar */
52 |
53 | .toolbar {
54 | --brand300: #9cc4d3;
55 | --brand400: #77adc1;
56 | --gray500: #787c91;
57 | --gray600: #3c3d49;
58 | --gray700: #22232a;
59 | --gray800: #17181c;
60 | --gray900: #0e0e11;
61 | --gray950: #09090b;
62 | --black: #1e1f24;
63 | --white: white;
64 |
65 | --bg-color: var(--gray900);
66 | --bg-light-color: var(--gray700);
67 | --border-color: var(--gray700);
68 | --dimmed-color: var(--gray500);
69 |
70 | --logo-color: white;
71 | --text-color: white;
72 | --text-size: 13px;
73 | --text-small-size: 12px;
74 | font-family:
75 | system-ui,
76 | -apple-system,
77 | BlinkMacSystemFont,
78 | "Segoe UI",
79 | Roboto,
80 | Oxygen,
81 | Ubuntu,
82 | Cantarell,
83 | "Open Sans",
84 | "Helvetica Neue",
85 | sans-serif;
86 | font-size: var(--text-size);
87 | }
88 |
89 | :focus {
90 | outline: none;
91 | }
92 |
93 | .dialog {
94 | color: #ffffff;
95 | box-sizing: border-box;
96 | background: var(--bg-color);
97 |
98 | border: 0;
99 | box-shadow:
100 | 0 10px 15px -3px rgba(0, 0, 0, 0.15),
101 | 0 4px 6px -2px rgba(0, 0, 0, 0.1),
102 | 0 -1px rgba(255, 255, 255, 0.1),
103 | 0 0 0 1px var(--border-color);
104 | border-radius: 7px;
105 | z-index: 999999;
106 | min-width: 240px;
107 | padding: 0;
108 |
109 | --visible-flags: 15;
110 | max-height: min(
111 | calc(100vh - 36px - 35px),
112 | calc(45px + (var(--visible-flags) * 27px))
113 | );
114 | height: auto;
115 |
116 | &[open] {
117 | display: flex;
118 | flex-direction: column;
119 | }
120 | }
121 |
122 | .dialog-content {
123 | overflow-y: auto;
124 | max-height: 100%;
125 | flex-grow: 1;
126 | margin: 3px 3px 3px 0;
127 |
128 | &::-webkit-scrollbar {
129 | width: 8px;
130 | height: 8px;
131 | }
132 | &::-webkit-scrollbar-track {
133 | background: transparent;
134 | }
135 | &::-webkit-scrollbar-thumb {
136 | background-color: rgba(255, 255, 255, 0.2);
137 | border-radius: 999px;
138 | transition: background-color 0.1s ease;
139 |
140 | &:hover {
141 | background-color: rgba(255, 255, 255, 0.3);
142 | }
143 | }
144 | &::-webkit-scrollbar-button {
145 | display: none;
146 | }
147 | }
148 |
149 | .toolbar-toggle {
150 | width: 36px;
151 | height: 36px;
152 | position: fixed;
153 | z-index: 999999;
154 | padding: 0;
155 | margin: 0;
156 | box-sizing: border-box;
157 |
158 | color: var(--logo-color);
159 | background: var(--bg-color);
160 |
161 | box-shadow:
162 | 0 10px 15px -3px rgba(0, 0, 0, 0.15),
163 | 0 4px 6px -2px rgba(0, 0, 0, 0.1),
164 | 0 -1px rgba(255, 255, 255, 0.1),
165 | 0 0 0 1px var(--border-color);
166 | border-radius: 999px;
167 |
168 | cursor: pointer;
169 |
170 | display: flex;
171 | justify-content: center;
172 | align-items: center;
173 |
174 | animation: bounceInUp 0.2s ease-out;
175 |
176 | transition: background 0.1s ease;
177 |
178 | &.open {
179 | background: var(--bg-light-color);
180 | }
181 |
182 | & .override-indicator {
183 | position: absolute;
184 | top: 1px;
185 | right: 1px;
186 | width: 8px;
187 | height: 8px;
188 | background-color: var(--brand400);
189 | border-radius: 50%;
190 | opacity: 0;
191 | transition: opacity 0.1s ease-in-out;
192 | box-shadow: inset 0px 1px rgba(255, 255, 255, 0.1);
193 |
194 | &.show {
195 | opacity: 1;
196 | animation: gelatine 0.5s;
197 | }
198 | }
199 | }
200 |
201 | .toolbar-header-button {
202 | background: none;
203 | border: none;
204 | color: var(--dimmed-color);
205 | cursor: pointer;
206 | border-radius: 4px;
207 | transition: background-color 0.1s ease;
208 | flex-shrink: 0;
209 | display: flex;
210 | align-items: center;
211 | justify-content: center;
212 | height: 28px;
213 | width: 28px;
214 |
215 | &:hover {
216 | background-color: var(--bg-light-color);
217 | color: var(--text-color);
218 | }
219 |
220 | &:focus-visible {
221 | outline: 1px solid #fff;
222 | outline-offset: 0px;
223 | }
224 |
225 | & + .button-tooltip {
226 | pointer-events: none;
227 | opacity: 0;
228 |
229 | background: var(--bg-color);
230 | color: var(--text-color);
231 | padding: 6px 8px;
232 | border-radius: 4px;
233 | font-size: 13px;
234 | }
235 |
236 | &:hover + .button-tooltip {
237 | opacity: 1;
238 | }
239 | }
240 |
241 | [data-tooltip] {
242 | position: relative;
243 | }
244 |
245 | [data-tooltip]:after {
246 | content: attr(data-tooltip);
247 | position: absolute;
248 | right: 100%;
249 | top: 0%;
250 | margin-right: 3px;
251 | user-select: none;
252 | pointer-events: none;
253 | background-color: var(--bg-color);
254 | border: 1px solid var(--border-color);
255 | border-radius: 4px;
256 | padding: 0px 6px;
257 | height: 28px;
258 | line-height: 26px;
259 | color: var(--text-color);
260 | font-size: var(--text-small-size);
261 | font-weight: normal;
262 | width: max-content;
263 | display: none;
264 | box-sizing: border-box;
265 | }
266 |
267 | [data-tooltip]:hover:after {
268 | display: block;
269 | }
270 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/test/mocks/handlers.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { DefaultBodyType, http, HttpResponse, StrictRequest } from "msw";
2 |
3 | import { RawFlags } from "../../src/flag/flags";
4 |
5 | export const testChannel = "testChannel";
6 |
7 | export const flagResponse = {
8 | success: true,
9 | features: {
10 | flagA: {
11 | isEnabled: true,
12 | key: "flagA",
13 | targetingVersion: 1,
14 | config: undefined,
15 | ruleEvaluationResults: [false, true],
16 | missingContextFields: ["field1", "field2"],
17 | },
18 | flagB: {
19 | isEnabled: true,
20 | targetingVersion: 11,
21 | key: "flagB",
22 | config: {
23 | version: 12,
24 | key: "gpt3",
25 | payload: { model: "gpt-something", temperature: 0.5 },
26 | ruleEvaluationResults: [true, false, false],
27 | missingContextFields: ["field3"],
28 | },
29 | },
30 | },
31 | };
32 |
33 | export const flagsResult = Object.entries(flagResponse.features).reduce(
34 | (acc, [key, flag]) => {
35 | acc[key] = {
36 | ...flag!,
37 | config: flag.config,
38 | isEnabledOverride: null,
39 | };
40 | return acc;
41 | },
42 | {} as RawFlags,
43 | );
44 |
45 | function checkRequest(request: StrictRequest<DefaultBodyType>) {
46 | const url = new URL(request.url);
47 | const hasKey =
48 | url.searchParams.get("publishableKey") ||
49 | request.headers.get("Authorization");
50 |
51 | const hasSdkVersion =
52 | url.searchParams.get("reflag-sdk-version") ||
53 | request.headers.get("reflag-sdk-version");
54 |
55 | const valid = hasKey && hasSdkVersion;
56 | if (!valid) {
57 | console.log(
58 | "missing token or sdk: " +
59 | request.url.toString() +
60 | " " +
61 | JSON.stringify(request.headers),
62 | );
63 | }
64 | return valid;
65 | }
66 |
67 | const invalidReqResponse = new HttpResponse("missing token or sdk", {
68 | status: 400,
69 | });
70 |
71 | export function getFlags({
72 | request,
73 | }: {
74 | request: StrictRequest<DefaultBodyType>;
75 | }) {
76 | if (!checkRequest(request)) return invalidReqResponse;
77 |
78 | return HttpResponse.json(flagResponse);
79 | }
80 |
81 | export const handlers = [
82 | http.post("https://front.reflag.com/user", async ({ request }) => {
83 | if (!checkRequest(request)) return invalidReqResponse;
84 |
85 | const data = await request.json();
86 | if (
87 | typeof data !== "object" ||
88 | !data ||
89 | !data["userId"] ||
90 | !data["attributes"]
91 | ) {
92 | return HttpResponse.error();
93 | }
94 |
95 | return HttpResponse.json({
96 | success: true,
97 | });
98 | }),
99 | http.post("https://front.reflag.com/company", async ({ request }) => {
100 | if (!checkRequest(request)) return invalidReqResponse;
101 | const data = await request.json();
102 |
103 | if (
104 | typeof data !== "object" ||
105 | !data ||
106 | !data["companyId"] ||
107 | !data["attributes"]
108 | ) {
109 | return HttpResponse.error();
110 | }
111 |
112 | return HttpResponse.json({
113 | success: true,
114 | });
115 | }),
116 | http.post("https://front.reflag.com/event", async ({ request }) => {
117 | if (!checkRequest(request)) return invalidReqResponse;
118 | const data = await request.json();
119 |
120 | if (typeof data !== "object" || !data || !data["userId"]) {
121 | return HttpResponse.error();
122 | }
123 |
124 | return HttpResponse.json({
125 | success: true,
126 | });
127 | }),
128 | http.post("https://front.reflag.com/features/events", async ({ request }) => {
129 | if (!checkRequest(request)) return invalidReqResponse;
130 | const data = await request.json();
131 |
132 | if (typeof data !== "object" || !data || !data["userId"]) {
133 | return HttpResponse.error();
134 | }
135 |
136 | return HttpResponse.json({
137 | success: true,
138 | });
139 | }),
140 | http.post("https://front.reflag.com/feedback", async ({ request }) => {
141 | if (!checkRequest(request)) return invalidReqResponse;
142 | const data = await request.json();
143 | if (
144 | typeof data !== "object" ||
145 | !data ||
146 | !data["userId"] ||
147 | typeof data["score"] !== "number" ||
148 | (!data["featureId"] && !data["key"])
149 | ) {
150 | return HttpResponse.error();
151 | }
152 |
153 | return HttpResponse.json({
154 | success: true,
155 | });
156 | }),
157 | http.get("https://front.reflag.com/features/enabled", getFlags),
158 | http.get("https://front.reflag.com/features/evaluated", getFlags),
159 | http.post(
160 | "https://front.reflag.com/feedback/prompting-init",
161 | ({ request }) => {
162 | if (!checkRequest(request)) return invalidReqResponse;
163 |
164 | return HttpResponse.json({ success: true, channel: testChannel });
165 | },
166 | ),
167 | http.get(
168 | "https://front.reflag.com/feedback/prompting-auth",
169 | ({ request }) => {
170 | if (!checkRequest(request)) return invalidReqResponse;
171 | return HttpResponse.json({ success: true, keyName: "keyName" });
172 | },
173 | ),
174 | http.post(
175 | "https://livemessaging.bucket.co/keys/keyName/requestToken",
176 | async ({ request }) => {
177 | const data = await request.json();
178 | if (typeof data !== "object") {
179 | return HttpResponse.error();
180 | }
181 |
182 | return HttpResponse.json({
183 | success: true,
184 | token: "token",
185 | expires: 1234567890,
186 | });
187 | },
188 | ),
189 | ];
190 |
```
--------------------------------------------------------------------------------
/packages/eslint-config/base.js:
--------------------------------------------------------------------------------
```javascript
1 | const jsPlugin = require("@eslint/js");
2 | const importsPlugin = require("eslint-plugin-import");
3 | const unusedImportsPlugin = require("eslint-plugin-unused-imports");
4 | const sortImportsPlugin = require("eslint-plugin-simple-import-sort");
5 | const { builtinModules } = require("module");
6 | const globals = require("globals");
7 | const tsPlugin = require("@typescript-eslint/eslint-plugin");
8 | const tsParser = require("@typescript-eslint/parser");
9 | const prettierConfig = require("eslint-config-prettier");
10 | const vuePlugin = require("eslint-plugin-vue");
11 |
12 | module.exports = [
13 | {
14 | // Blacklisted Folders, including **/node_modules/ and .git/
15 | ignores: ["build/", "**/gen"],
16 | },
17 | {
18 | // All files
19 | files: [
20 | "**/*.js",
21 | "**/*.cjs",
22 | "**/*.mjs",
23 | "**/*.jsx",
24 | "**/*.ts",
25 | "**/*.tsx",
26 | "**/*.vue",
27 | ],
28 | plugins: {
29 | import: importsPlugin,
30 | "unused-imports": unusedImportsPlugin,
31 | "simple-import-sort": sortImportsPlugin,
32 | },
33 | languageOptions: {
34 | globals: {
35 | ...globals.node,
36 | ...globals.browser,
37 | },
38 | parserOptions: {
39 | // Eslint doesn't supply ecmaVersion in `parser.js` `context.parserOptions`
40 | // This is required to avoid ecmaVersion < 2015 error or 'import' / 'export' error
41 | sourceType: "module",
42 | ecmaVersion: "latest",
43 | ecmaFeatures: {
44 | modules: true,
45 | impliedStrict: true,
46 | jsx: true,
47 | },
48 | },
49 | },
50 | settings: {
51 | react: {
52 | version: "detect",
53 | },
54 | "import/parsers": {
55 | // Workaround until import supports flat config
56 | // https://github.com/import-js/eslint-plugin-import/issues/2556
57 | espree: [".js", ".cjs", ".mjs", ".jsx"],
58 | },
59 | "import/resolver": {
60 | typescript: {
61 | alwaysTryTypes: true,
62 | },
63 | },
64 | },
65 | rules: {
66 | ...jsPlugin.configs.recommended.rules,
67 | ...importsPlugin.configs.recommended.rules,
68 |
69 | // Imports
70 | "unused-imports/no-unused-vars": [
71 | "warn",
72 | {
73 | vars: "all",
74 | varsIgnorePattern: "^_",
75 | args: "after-used",
76 | argsIgnorePattern: "^_",
77 | },
78 | ],
79 | "unused-imports/no-unused-imports": ["warn"],
80 | "import/first": ["warn"],
81 | "import/newline-after-import": ["warn"],
82 | "import/no-named-as-default": ["off"],
83 | "simple-import-sort/exports": ["warn"],
84 | "simple-import-sort/imports": [
85 | "warn",
86 | {
87 | groups: [
88 | // Side effect imports.
89 | ["^\\u0000"],
90 | // Node.js builtins, react, and third-party packages.
91 | [
92 | `^(${builtinModules.join("|")})(/|$)`,
93 | "^react",
94 | "^(?!@reflag)@?\\w",
95 | ],
96 | // Shared reflag packages.
97 | ["^@reflag/(.*)$"],
98 | // Path aliased root, parent imports, and just `..`.
99 | ["^@/", "^\\.\\.(?!/?$)", "^\\.\\./?$"],
100 | // Relative imports, same-folder imports, and just `.`.
101 | ["^\\./(?=.*/)(?!/?$)", "^\\.(?!/?$)", "^\\./?$"],
102 | // Style imports.
103 | ["^.+\\.s?css$"],
104 | ],
105 | },
106 | ],
107 | },
108 | },
109 | {
110 | // TypeScript files
111 | files: ["**/*.ts", "**/*.tsx"],
112 | plugins: {
113 | "@typescript-eslint": tsPlugin,
114 | vue: vuePlugin,
115 | },
116 | languageOptions: {
117 | parser: tsParser,
118 | parserOptions: {
119 | project: "./tsconfig.eslint.json",
120 | },
121 | },
122 | settings: {
123 | ...importsPlugin.configs.typescript.settings,
124 | "import/resolver": {
125 | ...importsPlugin.configs.typescript.settings["import/resolver"],
126 | typescript: {
127 | project: "./tsconfig.json",
128 | },
129 | },
130 | },
131 | rules: {
132 | ...importsPlugin.configs.typescript.rules,
133 | ...tsPlugin.configs["eslint-recommended"].overrides[0].rules,
134 | ...tsPlugin.configs.recommended.rules,
135 |
136 | // Typescript Specific
137 | "@typescript-eslint/no-unused-vars": ["off"], // handled by unused-imports
138 | "@typescript-eslint/explicit-module-boundary-types": ["off"],
139 | "@typescript-eslint/no-floating-promises": ["error"],
140 | "@typescript-eslint/switch-exhaustiveness-check": ["warn"],
141 | "@typescript-eslint/no-non-null-assertion": ["off"],
142 | "@typescript-eslint/no-empty-function": ["warn"],
143 | "@typescript-eslint/no-explicit-any": ["off"],
144 | "@typescript-eslint/no-use-before-define": ["off"],
145 | "@typescript-eslint/no-shadow": ["warn"],
146 | },
147 | },
148 |
149 | {
150 | files: ["**/*.tsx"],
151 | rules: {
152 | "react/prop-types": "off",
153 | },
154 | },
155 | {
156 | // Prettier Overrides
157 | files: [
158 | "**/*.js",
159 | "**/*.cjs",
160 | "**/*.mjs",
161 | "**/*.jsx",
162 | "**/*.ts",
163 | "**/*.tsx",
164 | "**/*.vue",
165 | ],
166 | rules: {
167 | ...prettierConfig.rules,
168 | },
169 | },
170 | ];
171 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/src/ui/packages/floating-ui-preact-dom/useFloating.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { computePosition } from "@floating-ui/dom";
2 | import {
3 | useCallback,
4 | useLayoutEffect,
5 | useMemo,
6 | useRef,
7 | useState,
8 | } from "preact/hooks";
9 |
10 | import { deepEqual } from "./utils/deepEqual";
11 | import { getDPR } from "./utils/getDPR";
12 | import { roundByDPR } from "./utils/roundByDPR";
13 | import { useLatestRef } from "./utils/useLatestRef";
14 | import type {
15 | ComputePositionConfig,
16 | ReferenceType,
17 | UseFloatingData,
18 | UseFloatingOptions,
19 | UseFloatingReturn,
20 | } from "./types";
21 |
22 | /**
23 | * Provides data to position a floating element.
24 | * @see https://floating-ui.com/docs/react
25 | */
26 | export function useFloating<RT extends ReferenceType = ReferenceType>(
27 | options: UseFloatingOptions = {},
28 | ): UseFloatingReturn<RT> {
29 | const {
30 | placement = "bottom",
31 | strategy = "absolute",
32 | middleware = [],
33 | platform,
34 | elements: { reference: externalReference, floating: externalFloating } = {},
35 | transform = true,
36 | whileElementsMounted,
37 | open,
38 | } = options;
39 |
40 | const [data, setData] = useState<UseFloatingData>({
41 | x: 0,
42 | y: 0,
43 | strategy,
44 | placement,
45 | middlewareData: {},
46 | isPositioned: false,
47 | });
48 |
49 | const [latestMiddleware, setLatestMiddleware] = useState(middleware);
50 |
51 | if (!deepEqual(latestMiddleware, middleware)) {
52 | setLatestMiddleware(middleware);
53 | }
54 |
55 | const [_reference, _setReference] = useState<RT | null>(null);
56 | const [_floating, _setFloating] = useState<HTMLElement | null>(null);
57 |
58 | const setReference = useCallback(
59 | (node: RT | null) => {
60 | if (node != referenceRef.current) {
61 | referenceRef.current = node;
62 | _setReference(node);
63 | }
64 | },
65 | [_setReference],
66 | );
67 |
68 | const setFloating = useCallback(
69 | (node: HTMLElement | null) => {
70 | if (node !== floatingRef.current) {
71 | floatingRef.current = node;
72 | _setFloating(node);
73 | }
74 | },
75 | [_setFloating],
76 | );
77 |
78 | const referenceEl = (externalReference || _reference) as RT | null;
79 | const floatingEl = externalFloating || _floating;
80 |
81 | const referenceRef = useRef<RT | null>(null);
82 | const floatingRef = useRef<HTMLElement | null>(null);
83 | const dataRef = useRef(data);
84 |
85 | const whileElementsMountedRef = useLatestRef(whileElementsMounted);
86 | const platformRef = useLatestRef(platform);
87 |
88 | const update = useCallback(() => {
89 | if (!referenceRef.current || !floatingRef.current) {
90 | return;
91 | }
92 |
93 | const config: ComputePositionConfig = {
94 | placement,
95 | strategy,
96 | middleware: latestMiddleware,
97 | };
98 |
99 | if (platformRef.current) {
100 | config.platform = platformRef.current;
101 | }
102 |
103 | /*eslint-disable-next-line @typescript-eslint/no-floating-promises*/
104 | computePosition(referenceRef.current, floatingRef.current, config).then(
105 | (positionData) => {
106 | const fullData = { ...positionData, isPositioned: true };
107 | if (isMountedRef.current && !deepEqual(dataRef.current, fullData)) {
108 | dataRef.current = fullData;
109 | setData(fullData);
110 | }
111 | },
112 | );
113 | }, [latestMiddleware, placement, strategy, platformRef]);
114 |
115 | useLayoutEffect(() => {
116 | if (open === false && dataRef.current.isPositioned) {
117 | dataRef.current.isPositioned = false;
118 | setData((positionData) => ({ ...positionData, isPositioned: false }));
119 | }
120 | }, [open]);
121 |
122 | const isMountedRef = useRef(false);
123 | useLayoutEffect(() => {
124 | isMountedRef.current = true;
125 | return () => {
126 | isMountedRef.current = false;
127 | };
128 | }, []);
129 |
130 | useLayoutEffect(() => {
131 | if (referenceEl) referenceRef.current = referenceEl;
132 | if (floatingEl) floatingRef.current = floatingEl;
133 |
134 | if (referenceEl && floatingEl) {
135 | if (whileElementsMountedRef.current) {
136 | return whileElementsMountedRef.current(referenceEl, floatingEl, update);
137 | } else {
138 | return update();
139 | }
140 | }
141 | }, [referenceEl, floatingEl, update, whileElementsMountedRef]);
142 |
143 | const refs = useMemo(
144 | () => ({
145 | reference: referenceRef,
146 | floating: floatingRef,
147 | setReference,
148 | setFloating,
149 | }),
150 | [setReference, setFloating],
151 | );
152 |
153 | const elements = useMemo(
154 | () => ({ reference: referenceEl, floating: floatingEl }),
155 | [referenceEl, floatingEl],
156 | );
157 |
158 | const floatingStyles = useMemo(() => {
159 | const initialStyles = {
160 | position: strategy,
161 | left: 0,
162 | top: 0,
163 | };
164 |
165 | if (!elements.floating) {
166 | return initialStyles;
167 | }
168 |
169 | const x = roundByDPR(elements.floating, data.x);
170 | const y = roundByDPR(elements.floating, data.y);
171 |
172 | if (transform) {
173 | return {
174 | ...initialStyles,
175 | transform: `translate(${x}px, ${y}px)`,
176 | ...(getDPR(elements.floating) >= 1.5 && { willChange: "transform" }),
177 | };
178 | }
179 |
180 | return {
181 | position: strategy,
182 | left: x,
183 | top: y,
184 | };
185 | }, [strategy, transform, elements.floating, data.x, data.y]);
186 |
187 | return useMemo(
188 | () => ({
189 | ...data,
190 | update,
191 | refs,
192 | elements,
193 | floatingStyles,
194 | }),
195 | [data, update, refs, elements, floatingStyles],
196 | );
197 | }
198 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/src/feedback/ui/StarRating.tsx:
--------------------------------------------------------------------------------
```typescript
1 | import { Fragment, FunctionComponent, h } from "preact";
2 | import { useRef } from "preact/hooks";
3 |
4 | import { Dissatisfied } from "../../ui/icons/Dissatisfied";
5 | import { Neutral } from "../../ui/icons/Neutral";
6 | import { Satisfied } from "../../ui/icons/Satisfied";
7 | import { VeryDissatisfied } from "../../ui/icons/VeryDissatisfied";
8 | import { VerySatisfied } from "../../ui/icons/VerySatisfied";
9 | import {
10 | arrow,
11 | offset,
12 | useFloating,
13 | } from "../../ui/packages/floating-ui-preact-dom";
14 |
15 | import { FeedbackTranslations } from "./types";
16 |
17 | const scores = [
18 | {
19 | color: "var(--reflag-feedback-dialog-rating-1-color, #dd6b20)",
20 | bg: "var(--reflag-feedback-dialog-rating-1-background-color, #fbd38d)",
21 | icon: <VeryDissatisfied />,
22 | getLabel: (t: FeedbackTranslations) => t.ScoreVeryDissatisfiedLabel,
23 | value: 1,
24 | },
25 | {
26 | color: "var(--reflag-feedback-dialog-rating-2-color, #ed8936)",
27 | bg: "var(--reflag-feedback-dialog-rating-2-background-color, #feebc8)",
28 | icon: <Dissatisfied />,
29 | getLabel: (t: FeedbackTranslations) => t.ScoreDissatisfiedLabel,
30 | value: 2,
31 | },
32 | {
33 | color: "var(--reflag-feedback-dialog-rating-3-color, #787c91)",
34 | bg: "var(--reflag-feedback-dialog-rating-3-background-color, #e9e9ed)",
35 | icon: <Neutral />,
36 | getLabel: (t: FeedbackTranslations) => t.ScoreNeutralLabel,
37 | value: 3,
38 | },
39 | {
40 | color: "var(--reflag-feedback-dialog-rating-4-color, #48bb78)",
41 | bg: "var(--reflag-feedback-dialog-rating-4-background-color, #c6f6d5)",
42 | icon: <Satisfied />,
43 | getLabel: (t: FeedbackTranslations) => t.ScoreSatisfiedLabel,
44 | value: 4,
45 | },
46 | {
47 | color: "var(--reflag-feedback-dialog-rating-5-color, #38a169)",
48 | bg: "var(--reflag-feedback-dialog-rating-5-background-color, #9ae6b4)",
49 | icon: <VerySatisfied />,
50 | getLabel: (t: FeedbackTranslations) => t.ScoreVerySatisfiedLabel,
51 | value: 5,
52 | },
53 | ] as const;
54 |
55 | type ScoreNumber = (typeof scores)[number];
56 |
57 | export type StarRatingProps = {
58 | name: string;
59 | selectedValue?: number;
60 | onChange?: h.JSX.GenericEventHandler<HTMLInputElement>;
61 | t: FeedbackTranslations;
62 | };
63 |
64 | export const StarRating: FunctionComponent<StarRatingProps> = ({
65 | t,
66 | name,
67 | selectedValue,
68 | onChange,
69 | }) => {
70 | return (
71 | <div class="star-rating">
72 | <style>
73 | {scores.map(
74 | ({ bg, color }, index) => `
75 | .star-rating-icons > input:nth-of-type(${
76 | index + 1
77 | }):checked + .button {
78 | border-color: ${color};
79 | }
80 |
81 | .star-rating-icons > input:nth-of-type(${
82 | index + 1
83 | }):checked + .button > div {
84 | background-color: ${bg};
85 | }
86 |
87 | .star-rating-icons > input:nth-of-type(${
88 | index + 1
89 | }):checked ~ input:nth-of-type(${index + 2}) + .button {
90 | border-left-color: ${color};
91 | }
92 | `,
93 | )}
94 | </style>
95 | <div class="star-rating-icons">
96 | {scores.map((score) => (
97 | <Score
98 | key={score.value}
99 | isSelected={score.value === selectedValue}
100 | name={name}
101 | score={score}
102 | t={t}
103 | onChange={onChange}
104 | />
105 | ))}
106 | </div>
107 | </div>
108 | );
109 | };
110 |
111 | const Score = ({
112 | isSelected,
113 | name,
114 | onChange,
115 | score,
116 | t,
117 | }: {
118 | isSelected: boolean;
119 | name: string;
120 | onChange?: h.JSX.GenericEventHandler<HTMLInputElement>;
121 | score: ScoreNumber;
122 | t: FeedbackTranslations;
123 | }) => {
124 | const arrowRef = useRef<HTMLDivElement>(null);
125 | const { refs, floatingStyles, middlewareData } = useFloating({
126 | placement: "top",
127 | middleware: [
128 | offset(4),
129 | arrow({
130 | element: arrowRef,
131 | }),
132 | ],
133 | });
134 |
135 | return (
136 | <>
137 | <input
138 | defaultChecked={isSelected}
139 | id={`reflag-feedback-score-${score.value}`}
140 | name={name}
141 | type="radio"
142 | value={score.value}
143 | onChange={onChange}
144 | />
145 | <label
146 | ref={refs.setReference}
147 | aria-label={score.getLabel(t)}
148 | class="button"
149 | for={`reflag-feedback-score-${score.value}`}
150 | style={{ color: score.color }}
151 | >
152 | <div
153 | style={{
154 | position: "absolute",
155 | top: 0,
156 | left: 0,
157 | width: "100%",
158 | height: "100%",
159 | opacity: 0.2, // TODO: fix overflow
160 | zIndex: 1,
161 | }}
162 | />
163 | <span style={{ zIndex: 2, display: "flex", alignItems: "center" }}>
164 | {score.icon}
165 | </span>
166 | </label>
167 | <div ref={refs.setFloating} class="button-tooltip" style={floatingStyles}>
168 | {score.getLabel(t)}
169 | <div
170 | ref={arrowRef}
171 | class="button-tooltip-arrow"
172 | style={{
173 | left:
174 | middlewareData.arrow?.x != null
175 | ? `${middlewareData.arrow.x}px`
176 | : "",
177 | top:
178 | middlewareData.arrow?.y != null
179 | ? `${middlewareData.arrow.y}px`
180 | : "",
181 | }}
182 | />
183 | </div>
184 | </>
185 | );
186 | };
187 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/test/prompts.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
2 |
3 | import {
4 | parsePromptMessage,
5 | processPromptMessage,
6 | } from "../src/feedback/prompts";
7 | import {
8 | checkPromptMessageCompleted,
9 | markPromptMessageCompleted,
10 | } from "../src/feedback/promptStorage";
11 |
12 | vi.mock("../src/feedback/promptStorage", () => {
13 | return {
14 | markPromptMessageCompleted: vi.fn(),
15 | checkPromptMessageCompleted: vi.fn(),
16 | };
17 | });
18 |
19 | describe("parsePromptMessage", () => {
20 | test("will not parse invalid messages", () => {
21 | expect(parsePromptMessage(undefined)).toBeUndefined();
22 | expect(parsePromptMessage("invalid")).toBeUndefined();
23 | expect(
24 | parsePromptMessage({ showAfter: Date.now(), showBefore: Date.now() }),
25 | ).toBeUndefined();
26 | expect(
27 | parsePromptMessage({
28 | question: "",
29 | showAfter: Date.now(),
30 | showBefore: Date.now(),
31 | }),
32 | ).toBeUndefined();
33 | expect(
34 | parsePromptMessage({
35 | question: "hello?",
36 | showBefore: Date.now(),
37 | promptId: "123",
38 | featureId: "123",
39 | }),
40 | ).toBeUndefined();
41 | expect(
42 | parsePromptMessage({
43 | question: "hello?",
44 | showAfter: Date.now(),
45 | promptId: "123",
46 | featureId: "123",
47 | }),
48 | ).toBeUndefined();
49 | expect(
50 | parsePromptMessage({
51 | question: "hello?",
52 | showAfter: Date.now(),
53 | showBefore: Date.now(),
54 | }),
55 | ).toBeUndefined();
56 | expect(
57 | parsePromptMessage({
58 | question: "hello?",
59 | showAfter: Date.now(),
60 | showBefore: Date.now(),
61 | promptId: "123",
62 | }),
63 | ).toBeUndefined();
64 | expect(
65 | parsePromptMessage({
66 | question: "hello?",
67 | showAfter: Date.now(),
68 | showBefore: Date.now(),
69 | featureId: "123",
70 | }),
71 | ).toBeUndefined();
72 | });
73 |
74 | test("will parse valid messages", () => {
75 | const start = Date.parse("2021-01-01T00:00:00.000Z");
76 | const end = Date.parse("2021-01-01T10:00:00.000Z");
77 |
78 | expect(
79 | parsePromptMessage({
80 | question: "hello?",
81 | showAfter: start,
82 | showBefore: end,
83 | promptId: "123",
84 | featureId: "456",
85 | }),
86 | ).toEqual({
87 | question: "hello?",
88 | showAfter: new Date(start),
89 | showBefore: new Date(end),
90 | promptId: "123",
91 | featureId: "456",
92 | });
93 | });
94 | });
95 |
96 | describe("processPromptMessage", () => {
97 | const now = Date.now();
98 | const promptTemplate = {
99 | question: "hello?",
100 | promptId: "123",
101 | featureId: "456",
102 | };
103 |
104 | beforeEach(() => {
105 | vi.mocked(checkPromptMessageCompleted).mockReturnValue(false);
106 | vi.useFakeTimers();
107 | });
108 |
109 | afterEach(() => {
110 | vi.useRealTimers();
111 | vi.clearAllMocks();
112 | });
113 |
114 | test("will not process seen prompts", () => {
115 | vi.mocked(checkPromptMessageCompleted).mockReturnValue(true);
116 |
117 | const prompt = {
118 | ...promptTemplate,
119 | showAfter: new Date(now - 10000),
120 | showBefore: new Date(now + 10000),
121 | };
122 |
123 | const showCallback = vi.fn();
124 |
125 | expect(processPromptMessage("user", prompt, showCallback)).toBe(false);
126 |
127 | expect(showCallback).not.toHaveBeenCalled();
128 | expect(markPromptMessageCompleted).not.toHaveBeenCalled();
129 | });
130 |
131 | test("will not process expired prompts", () => {
132 | const prompt = {
133 | ...promptTemplate,
134 | showAfter: new Date(now - 10000),
135 | showBefore: new Date(now - 5000),
136 | };
137 |
138 | const showCallback = vi.fn();
139 |
140 | expect(processPromptMessage("user", prompt, showCallback)).toBe(false);
141 |
142 | expect(showCallback).not.toHaveBeenCalled();
143 |
144 | expect(markPromptMessageCompleted).not.toHaveBeenCalled();
145 | });
146 |
147 | test("will process prompts that are ready to be shown", () => {
148 | const prompt = {
149 | ...promptTemplate,
150 | showAfter: new Date(now - 10000),
151 | showBefore: new Date(now + 10000),
152 | };
153 |
154 | const showCallback = vi
155 | .fn()
156 | .mockImplementation((_a, _b, actionedCallback) => {
157 | actionedCallback();
158 | });
159 |
160 | expect(processPromptMessage("user", prompt, showCallback)).toBe(true);
161 | expect(showCallback).toHaveBeenCalledWith(
162 | "user",
163 | prompt,
164 | expect.any(Function),
165 | );
166 |
167 | expect(markPromptMessageCompleted).toHaveBeenCalledOnce();
168 | expect(markPromptMessageCompleted).toBeCalledWith(
169 | "user",
170 | "123",
171 | prompt.showBefore,
172 | );
173 | });
174 |
175 | test("will process and delay prompts that are not yet ready to be shown", () => {
176 | const prompt = {
177 | ...promptTemplate,
178 | showAfter: new Date(now + 5000),
179 | showBefore: new Date(now + 10000),
180 | };
181 |
182 | const showCallback = vi
183 | .fn()
184 | .mockImplementation((_a, _b, actionedCallback) => {
185 | actionedCallback();
186 | });
187 |
188 | expect(processPromptMessage("user", prompt, showCallback)).toBe(true);
189 | expect(showCallback).not.toHaveBeenCalled();
190 | expect(localStorage.getItem("prompt-user")).toBeNull();
191 |
192 | vi.runAllTimers();
193 | expect(showCallback).toHaveBeenCalledWith(
194 | "user",
195 | prompt,
196 | expect.any(Function),
197 | );
198 |
199 | expect(markPromptMessageCompleted).toHaveBeenCalledOnce();
200 | expect(markPromptMessageCompleted).toBeCalledWith(
201 | "user",
202 | "123",
203 | prompt.showBefore,
204 | );
205 | });
206 | });
207 |
```
--------------------------------------------------------------------------------
/packages/cli/commands/mcp.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { select } from "@inquirer/prompts";
2 | import chalk from "chalk";
3 | import { Command } from "commander";
4 | import { parse as parseJSON, stringify as stringifyJSON } from "comment-json";
5 | import { mkdir, readFile, writeFile } from "node:fs/promises";
6 | import { dirname, join, relative } from "node:path";
7 | import ora, { type Ora } from "ora";
8 |
9 | import {
10 | ConfigPaths,
11 | getServersConfig,
12 | resolveConfigPath,
13 | SupportedEditor,
14 | SupportedEditors,
15 | } from "../services/mcp.js";
16 | import { configStore } from "../stores/config.js";
17 | import { handleError } from "../utils/errors.js";
18 | import { fileExists } from "../utils/file.js";
19 | import { configScopeOption, editorOption } from "../utils/options.js";
20 |
21 | export const mcpAction = async (options: {
22 | editor?: SupportedEditor;
23 | scope?: "local" | "global";
24 | }) => {
25 | const config = configStore.getConfig();
26 | let spinner: Ora | undefined;
27 | let selectedEditor = options.editor;
28 |
29 | // Select Editor/Client
30 | if (!selectedEditor) {
31 | selectedEditor = await select<SupportedEditor>({
32 | message: "Which editor do you want to configure?",
33 | choices: SupportedEditors.map((value) => ({
34 | value,
35 | name: ConfigPaths[value].name,
36 | })),
37 | });
38 | }
39 |
40 | // Determine Config Path
41 | const projectPath = configStore.getProjectPath();
42 | const globalPath = resolveConfigPath(selectedEditor, false);
43 | const localPath = resolveConfigPath(selectedEditor, true);
44 | const fullLocalPath = localPath ? join(projectPath, localPath) : undefined;
45 |
46 | if (!globalPath) {
47 | throw new Error(`Unsupported platform for editor: ${selectedEditor}`);
48 | }
49 |
50 | let configPathType: "global" | "local" = "global";
51 | if (fullLocalPath) {
52 | if (options.scope) {
53 | configPathType = options.scope;
54 | } else {
55 | configPathType = await select<"global" | "local">({
56 | message: "Configure global or project-local settings?",
57 | choices: [
58 | {
59 | name: `Local (${relative(projectPath, fullLocalPath)})`,
60 | value: "local",
61 | },
62 | { name: `Global (${globalPath})`, value: "global" },
63 | ],
64 | });
65 | }
66 | }
67 | const configPath = configPathType === "local" ? fullLocalPath! : globalPath;
68 | const displayConfigPath =
69 | configPathType === "local" ? relative(projectPath, configPath) : configPath;
70 |
71 | // Read/Parse Config File
72 | spinner = ora(
73 | `Reading configuration file: ${chalk.cyan(displayConfigPath)}...`,
74 | ).start();
75 |
76 | let editorConfig: any = {};
77 | if (await fileExists(configPath)) {
78 | const content = await readFile(configPath, "utf-8");
79 | // Attempt to parse JSON, handle potential comments if needed
80 | try {
81 | editorConfig = parseJSON(content);
82 | } catch {
83 | spinner.fail(
84 | `Failed to parse configuration file ${chalk.cyan(displayConfigPath)}.`,
85 | );
86 | }
87 | spinner.succeed(
88 | `Read configuration file ${chalk.cyan(displayConfigPath)}.`,
89 | );
90 | } else {
91 | spinner.info("Configuration file not found, will create a new one.");
92 | editorConfig = {}; // Initialize empty config if file doesn't exist
93 | }
94 |
95 | // Ensure MCP servers object exists
96 | const serversConfig = getServersConfig(
97 | editorConfig,
98 | selectedEditor,
99 | configPathType,
100 | );
101 |
102 | // Check for existing Reflag servers
103 | const existingReflagEntries = Object.keys(serversConfig).filter((key) =>
104 | /reflag/i.test(key),
105 | );
106 |
107 | // Prompt for Add/Update
108 | let targetEntryKey: string;
109 | const defaultNewKey = `Reflag`;
110 |
111 | if (existingReflagEntries.length === 0) {
112 | targetEntryKey = defaultNewKey;
113 | console.log(`Adding new MCP server entry: ${chalk.cyan(targetEntryKey)}`);
114 | } else {
115 | const choices = [
116 | { name: `Add: ${defaultNewKey}`, value: "add_new" },
117 | ...existingReflagEntries.map((key) => ({
118 | name: `Update: ${key}`,
119 | value: key,
120 | })),
121 | ];
122 |
123 | const choice = await select({
124 | message: "Add a new MCP server or update an existing one?",
125 | choices,
126 | });
127 |
128 | if (choice === "add_new") {
129 | targetEntryKey = defaultNewKey;
130 | console.log(`Adding new MCP server entry: ${chalk.cyan(targetEntryKey)}`);
131 | } else {
132 | targetEntryKey = choice;
133 | console.log(
134 | `Updating existing MCP server entry: ${chalk.cyan(targetEntryKey)}`,
135 | );
136 | }
137 | }
138 |
139 | // Construct the MCP endpoint URL
140 | const newEntryValue = {
141 | url: config.apiUrl + "/mcp",
142 | };
143 |
144 | // Update Config Object
145 | serversConfig[targetEntryKey] = newEntryValue;
146 |
147 | // Write Config File
148 | spinner = ora(
149 | `Writing configuration to ${chalk.cyan(displayConfigPath)}...`,
150 | ).start();
151 |
152 | try {
153 | // Ensure the directory exists before writing
154 | await mkdir(dirname(configPath), { recursive: true });
155 | const configString = stringifyJSON(editorConfig, null, 2);
156 |
157 | await writeFile(configPath, configString);
158 | spinner.succeed(
159 | `Configuration updated successfully in ${chalk.cyan(displayConfigPath)}.`,
160 | );
161 |
162 | console.log(
163 | chalk.grey(
164 | "You may need to restart your editor for changes to take effect.",
165 | ),
166 | );
167 | } catch (error) {
168 | spinner.fail(
169 | `Failed to write configuration file ${chalk.cyan(displayConfigPath)}.`,
170 | );
171 |
172 | handleError(error, "MCP Configuration");
173 | }
174 | };
175 |
176 | export function registerMcpCommand(cli: Command) {
177 | cli
178 | .command("mcp")
179 | .description("Configure Reflag's remote MCP server for your AI assistant.")
180 | .addOption(editorOption)
181 | .addOption(configScopeOption)
182 | .action(mcpAction);
183 | }
184 |
```
--------------------------------------------------------------------------------
/packages/react-sdk/dev/nextjs-flag-demo/app/page.tsx:
--------------------------------------------------------------------------------
```typescript
1 | import Image from "next/image";
2 | import { Flags } from "@/components/Flags";
3 |
4 | export default function Home() {
5 | return (
6 | <main className="flex min-h-screen flex-col items-center justify-between p-24">
7 | <div className="z-10 w-full max-w-5xl items-center justify-between font-mono text-sm lg:flex">
8 | <div className="fixed bottom-0 left-0 flex h-48 w-full items-end justify-center bg-gradient-to-t from-white via-white dark:from-black dark:via-black lg:static lg:size-auto lg:bg-none">
9 | <a
10 | className="pointer-events-none flex place-items-center gap-2 p-8 lg:pointer-events-auto lg:p-0"
11 | href="https://vercel.com?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
12 | target="_blank"
13 | rel="noopener noreferrer"
14 | >
15 | By{" "}
16 | <Image
17 | src="/vercel.svg"
18 | alt="Vercel Logo"
19 | className="dark:invert"
20 | width={100}
21 | height={24}
22 | priority
23 | />
24 | </a>
25 | </div>
26 | </div>
27 |
28 | <div className="relative z-[-1] flex place-items-center before:absolute before:h-[300px] before:w-full before:-translate-x-1/2 before:rounded-full before:bg-gradient-radial before:from-white before:to-transparent before:blur-2xl before:content-[''] after:absolute after:-z-20 after:h-[180px] after:w-full after:translate-x-1/3 after:bg-gradient-conic after:from-sky-200 after:via-blue-200 after:blur-2xl after:content-[''] before:dark:bg-gradient-to-br before:dark:from-transparent before:dark:to-blue-700 before:dark:opacity-10 after:dark:from-sky-900 after:dark:via-[#0141ff] after:dark:opacity-40 sm:before:w-[480px] sm:after:w-[240px] before:lg:h-[360px]">
29 | <Image
30 | className="relative dark:drop-shadow-[0_0_0.3rem_#ffffff70] dark:invert"
31 | src="/next.svg"
32 | alt="Next.js Logo"
33 | width={180}
34 | height={37}
35 | priority
36 | />
37 | </div>
38 |
39 | <Flags />
40 |
41 | <div className="mb-32 grid text-center lg:mb-0 lg:w-full lg:max-w-5xl lg:grid-cols-4 lg:text-left">
42 | <a
43 | href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
44 | className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
45 | target="_blank"
46 | rel="noopener noreferrer"
47 | >
48 | <h2 className="mb-3 text-2xl font-semibold">
49 | Docs{" "}
50 | <span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
51 | ->
52 | </span>
53 | </h2>
54 | <p className="m-0 max-w-[30ch] text-sm opacity-50">
55 | Find in-depth information about Next.js features and API.
56 | </p>
57 | </a>
58 |
59 | <a
60 | href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
61 | className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
62 | target="_blank"
63 | rel="noopener noreferrer"
64 | >
65 | <h2 className="mb-3 text-2xl font-semibold">
66 | Learn{" "}
67 | <span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
68 | ->
69 | </span>
70 | </h2>
71 | <p className="m-0 max-w-[30ch] text-sm opacity-50">
72 | Learn about Next.js in an interactive course with quizzes!
73 | </p>
74 | </a>
75 |
76 | <a
77 | href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
78 | className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
79 | target="_blank"
80 | rel="noopener noreferrer"
81 | >
82 | <h2 className="mb-3 text-2xl font-semibold">
83 | Templates{" "}
84 | <span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
85 | ->
86 | </span>
87 | </h2>
88 | <p className="m-0 max-w-[30ch] text-sm opacity-50">
89 | Explore starter templates for Next.js.
90 | </p>
91 | </a>
92 |
93 | <a
94 | href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
95 | className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
96 | target="_blank"
97 | rel="noopener noreferrer"
98 | >
99 | <h2 className="mb-3 text-2xl font-semibold">
100 | Deploy{" "}
101 | <span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
102 | ->
103 | </span>
104 | </h2>
105 | <p className="m-0 max-w-[30ch] text-balance text-sm opacity-50">
106 | Instantly deploy your Next.js site to a shareable URL with Vercel.
107 | </p>
108 | </a>
109 | </div>
110 | </main>
111 | );
112 | }
113 |
```
--------------------------------------------------------------------------------
/packages/react-sdk/dev/nextjs-bootstrap-demo/app/page.tsx:
--------------------------------------------------------------------------------
```typescript
1 | import Image from "next/image";
2 | import { Flags } from "@/components/Flags";
3 |
4 | export default async function Home() {
5 | return (
6 | <main className="flex min-h-screen flex-col items-center justify-between p-24">
7 | <div className="z-10 w-full max-w-5xl items-center justify-between font-mono text-sm lg:flex">
8 | <div className="fixed bottom-0 left-0 flex h-48 w-full items-end justify-center bg-gradient-to-t from-white via-white dark:from-black dark:via-black lg:static lg:size-auto lg:bg-none">
9 | <a
10 | className="pointer-events-none flex place-items-center gap-2 p-8 lg:pointer-events-auto lg:p-0"
11 | href="https://vercel.com?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
12 | target="_blank"
13 | rel="noopener noreferrer"
14 | >
15 | By{" "}
16 | <Image
17 | src="/vercel.svg"
18 | alt="Vercel Logo"
19 | className="dark:invert w-auto h-auto"
20 | width={100}
21 | height={24}
22 | priority
23 | />
24 | </a>
25 | </div>
26 | </div>
27 |
28 | <div className="relative z-[-1] flex place-items-center before:absolute before:h-[300px] before:w-full before:-translate-x-1/2 before:rounded-full before:bg-gradient-radial before:from-white before:to-transparent before:blur-2xl before:content-[''] after:absolute after:-z-20 after:h-[180px] after:w-full after:translate-x-1/3 after:bg-gradient-conic after:from-sky-200 after:via-blue-200 after:blur-2xl after:content-[''] before:dark:bg-gradient-to-br before:dark:from-transparent before:dark:to-blue-700 before:dark:opacity-10 after:dark:from-sky-900 after:dark:via-[#0141ff] after:dark:opacity-40 sm:before:w-[480px] sm:after:w-[240px] before:lg:h-[360px]">
29 | <Image
30 | className="relative dark:drop-shadow-[0_0_0.3rem_#ffffff70] dark:invert"
31 | src="/next.svg"
32 | alt="Next.js Logo"
33 | width={180}
34 | height={37}
35 | priority
36 | />
37 | </div>
38 |
39 | <Flags />
40 |
41 | <div className="mb-32 grid text-center lg:mb-0 lg:w-full lg:max-w-5xl lg:grid-cols-4 lg:text-left">
42 | <a
43 | href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
44 | className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
45 | target="_blank"
46 | rel="noopener noreferrer"
47 | >
48 | <h2 className="mb-3 text-2xl font-semibold">
49 | Docs{" "}
50 | <span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
51 | ->
52 | </span>
53 | </h2>
54 | <p className="m-0 max-w-[30ch] text-sm opacity-50">
55 | Find in-depth information about Next.js features and API.
56 | </p>
57 | </a>
58 |
59 | <a
60 | href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
61 | className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
62 | target="_blank"
63 | rel="noopener noreferrer"
64 | >
65 | <h2 className="mb-3 text-2xl font-semibold">
66 | Learn{" "}
67 | <span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
68 | ->
69 | </span>
70 | </h2>
71 | <p className="m-0 max-w-[30ch] text-sm opacity-50">
72 | Learn about Next.js in an interactive course with quizzes!
73 | </p>
74 | </a>
75 |
76 | <a
77 | href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
78 | className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
79 | target="_blank"
80 | rel="noopener noreferrer"
81 | >
82 | <h2 className="mb-3 text-2xl font-semibold">
83 | Templates{" "}
84 | <span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
85 | ->
86 | </span>
87 | </h2>
88 | <p className="m-0 max-w-[30ch] text-sm opacity-50">
89 | Explore starter templates for Next.js.
90 | </p>
91 | </a>
92 |
93 | <a
94 | href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
95 | className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
96 | target="_blank"
97 | rel="noopener noreferrer"
98 | >
99 | <h2 className="mb-3 text-2xl font-semibold">
100 | Deploy{" "}
101 | <span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
102 | ->
103 | </span>
104 | </h2>
105 | <p className="m-0 max-w-[30ch] text-balance text-sm opacity-50">
106 | Instantly deploy your Next.js site to a shareable URL with Vercel.
107 | </p>
108 | </a>
109 | </div>
110 | </main>
111 | );
112 | }
113 |
```
--------------------------------------------------------------------------------
/packages/node-sdk/test/flusher.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { constants } from "os";
2 | import {
3 | afterEach,
4 | beforeEach,
5 | describe,
6 | expect,
7 | it,
8 | MockInstance,
9 | vi,
10 | } from "vitest";
11 |
12 | import { subscribe } from "../src/flusher";
13 |
14 | describe("flusher", () => {
15 | const mockExit = vi
16 | .spyOn(process, "exit")
17 | .mockImplementation((() => undefined) as any);
18 |
19 | const mockConsoleError = vi
20 | .spyOn(console, "error")
21 | .mockImplementation(() => undefined);
22 |
23 | const mockProcessOn = vi
24 | .spyOn(process, "on")
25 | .mockImplementation((_, __) => process);
26 |
27 | const mockProcessPrependListener = (
28 | vi.spyOn(process, "prependListener") as unknown as MockInstance<
29 | [event: NodeJS.Signals, listener: NodeJS.SignalsListener],
30 | NodeJS.Process
31 | >
32 | ).mockImplementation((_, __) => process);
33 |
34 | const mockListenerCount = vi
35 | .spyOn(process, "listenerCount")
36 | .mockReturnValue(0);
37 |
38 | function timedCallback(ms: number) {
39 | return vi.fn().mockImplementation(
40 | () =>
41 | new Promise((resolve) => {
42 | setTimeout(resolve, ms);
43 | }),
44 | );
45 | }
46 |
47 | function getHandler(eventName: string, prepended = false) {
48 | return prepended
49 | ? mockProcessPrependListener.mock.calls.filter(
50 | ([evt]) => evt === eventName,
51 | )[0][1]
52 | : mockProcessOn.mock.calls.filter(([evt]) => evt === eventName)[0][1];
53 | }
54 |
55 | beforeEach(() => {
56 | vi.useFakeTimers();
57 | });
58 |
59 | afterEach(() => {
60 | vi.useRealTimers();
61 | vi.resetAllMocks();
62 | });
63 |
64 | describe("signal handling", () => {
65 | const signals = ["SIGINT", "SIGTERM", "SIGHUP", "SIGBREAK"] as const;
66 |
67 | describe.each(signals)("signal %s", (signal) => {
68 | it("should handle signal with no existing listeners", async () => {
69 | mockListenerCount.mockReturnValue(0);
70 | const callback = vi.fn().mockResolvedValue(undefined);
71 |
72 | subscribe(callback);
73 | expect(mockProcessOn).toHaveBeenCalledWith(
74 | signal,
75 | expect.any(Function),
76 | );
77 |
78 | getHandler(signal)(signal);
79 | await vi.runAllTimersAsync();
80 |
81 | expect(callback).toHaveBeenCalledTimes(1);
82 | expect(mockExit).toHaveBeenCalledWith(0x80 + constants.signals[signal]);
83 | });
84 |
85 | it("should prepend handler when listeners exist", async () => {
86 | mockListenerCount.mockReturnValue(1);
87 | const callback = vi.fn().mockResolvedValue(undefined);
88 |
89 | subscribe(callback);
90 |
91 | expect(mockProcessPrependListener).toHaveBeenCalledWith(
92 | signal,
93 | expect.any(Function),
94 | );
95 |
96 | getHandler(signal, true)(signal);
97 |
98 | expect(callback).toHaveBeenCalledTimes(1);
99 | expect(mockExit).not.toHaveBeenCalled();
100 | });
101 | });
102 | });
103 |
104 | describe("beforeExit handling", () => {
105 | it("should call callback on beforeExit", async () => {
106 | const callback = vi.fn().mockResolvedValue(undefined);
107 |
108 | subscribe(callback);
109 |
110 | getHandler("beforeExit")();
111 |
112 | expect(callback).toHaveBeenCalledTimes(1);
113 | });
114 |
115 | it("should not call callback multiple times", async () => {
116 | const callback = vi.fn().mockResolvedValue(undefined);
117 |
118 | subscribe(callback);
119 |
120 | getHandler("beforeExit")();
121 | getHandler("beforeExit")();
122 |
123 | expect(callback).toHaveBeenCalledTimes(1);
124 | });
125 | });
126 |
127 | describe("timeout handling", () => {
128 | it("should handle timeout when callback takes too long", async () => {
129 | subscribe(timedCallback(2000), 1000);
130 |
131 | getHandler("beforeExit")();
132 |
133 | await vi.advanceTimersByTimeAsync(1000);
134 |
135 | expect(mockConsoleError).toHaveBeenCalledWith(
136 | "[Reflag SDK] Timeout while flushing events on process exit.",
137 | );
138 | });
139 |
140 | it("should not timeout when callback completes in time", async () => {
141 | subscribe(timedCallback(500), 1000);
142 |
143 | getHandler("beforeExit")();
144 | await vi.advanceTimersByTimeAsync(500);
145 |
146 | expect(mockConsoleError).not.toHaveBeenCalled();
147 | });
148 | });
149 |
150 | describe("exit state handling", () => {
151 | it("should log error if exit occurs before flushing starts", () => {
152 | subscribe(timedCallback(0));
153 |
154 | getHandler("exit")();
155 |
156 | expect(mockConsoleError).toHaveBeenCalledWith(
157 | "[Reflag SDK] Failed to finalize the flushing of events on process exit.",
158 | );
159 | });
160 |
161 | it("should log error if exit occurs before flushing completes", async () => {
162 | subscribe(timedCallback(2000));
163 | getHandler("beforeExit")();
164 |
165 | await vi.advanceTimersByTimeAsync(1000);
166 |
167 | getHandler("exit")();
168 |
169 | expect(mockConsoleError).toHaveBeenCalledWith(
170 | "[Reflag SDK] Failed to finalize the flushing of events on process exit.",
171 | );
172 | });
173 |
174 | it("should not log error if flushing completes before exit", async () => {
175 | subscribe(timedCallback(500));
176 |
177 | getHandler("beforeExit")();
178 | await vi.advanceTimersByTimeAsync(500);
179 |
180 | getHandler("exit")();
181 |
182 | expect(mockConsoleError).not.toHaveBeenCalled();
183 | });
184 |
185 | it("should handle callback errors gracefully", async () => {
186 | subscribe(vi.fn().mockRejectedValue(new Error("Test error")));
187 |
188 | getHandler("beforeExit")();
189 | await vi.runAllTimersAsync();
190 |
191 | expect(mockConsoleError).toHaveBeenCalledWith(
192 | "[Reflag SDK] An error occurred while flushing events on process exit.",
193 | expect.any(Error),
194 | );
195 | });
196 | });
197 |
198 | it("should run the callback only once", async () => {
199 | const callback = vi.fn().mockResolvedValue(undefined);
200 |
201 | subscribe(callback);
202 |
203 | getHandler("SIGINT")("SIGINT");
204 | getHandler("beforeExit")();
205 |
206 | await vi.runAllTimersAsync();
207 |
208 | expect(callback).toHaveBeenCalledTimes(1);
209 | });
210 | });
211 |
```
--------------------------------------------------------------------------------
/packages/cli/stores/config.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Ajv, ValidateFunction } from "ajv";
2 | import {
3 | assign as assignJSON,
4 | parse as parseJSON,
5 | stringify as stringifyJSON,
6 | } from "comment-json";
7 | import equal from "fast-deep-equal";
8 | import { findUp } from "find-up";
9 | import { readFile, writeFile } from "node:fs/promises";
10 | import { dirname, join } from "node:path";
11 |
12 | import {
13 | CONFIG_FILE_NAME,
14 | DEFAULT_API_URL,
15 | DEFAULT_BASE_URL,
16 | DEFAULT_TYPES_OUTPUT,
17 | MODULE_ROOT,
18 | SCHEMA_URL,
19 | } from "../utils/constants.js";
20 | import { ConfigValidationError, handleError } from "../utils/errors.js";
21 | import { stripTrailingSlash } from "../utils/urls.js";
22 | import { current as currentVersion } from "../utils/version.js";
23 |
24 | export const typeFormats = ["react", "node"] as const;
25 | export type TypeFormat = (typeof typeFormats)[number];
26 |
27 | export type TypesOutput = {
28 | path: string;
29 | format: TypeFormat;
30 | };
31 |
32 | type Config = {
33 | $schema: string;
34 | baseUrl: string;
35 | apiUrl: string;
36 | appId: string | undefined;
37 | typesOutput: TypesOutput[];
38 | };
39 |
40 | const defaultConfig: Config = {
41 | $schema: SCHEMA_URL,
42 | baseUrl: DEFAULT_BASE_URL,
43 | apiUrl: DEFAULT_API_URL,
44 | appId: undefined,
45 | typesOutput: [{ path: DEFAULT_TYPES_OUTPUT, format: "react" }],
46 | };
47 |
48 | // Helper to normalize typesOutput to array format
49 | export function normalizeTypesOutput(
50 | output?: string | TypesOutput[],
51 | ): TypesOutput[] | undefined {
52 | if (!output) return undefined;
53 | if (typeof output === "string") {
54 | return [{ path: output, format: "react" }];
55 | }
56 | return output;
57 | }
58 |
59 | class ConfigStore {
60 | protected config: Config = { ...defaultConfig };
61 | protected configPath: string | undefined;
62 | protected projectPath: string | undefined;
63 | protected clientVersion: string | undefined;
64 | protected validateConfig: ValidateFunction | undefined;
65 |
66 | async initialize() {
67 | await this.createValidator();
68 | await this.loadConfigFile();
69 | }
70 |
71 | protected async createValidator() {
72 | try {
73 | // Using current config store file, resolve the schema.json path
74 | const schemaPath = join(MODULE_ROOT, "schema.json");
75 | const content = await readFile(schemaPath, "utf-8");
76 | const parsed = parseJSON(content) as unknown as Config;
77 |
78 | const ajv = new Ajv();
79 | this.validateConfig = ajv.compile(parsed);
80 | } catch {
81 | handleError(new Error("Failed to load the config schema"), "Config");
82 | }
83 | }
84 |
85 | protected async loadConfigFile() {
86 | if (!this.validateConfig) {
87 | handleError(new Error("Failed to load the config schema"), "Config");
88 | }
89 |
90 | // Load the client version from the module's package.json metadata
91 | try {
92 | const { version } = await currentVersion();
93 | this.clientVersion = version;
94 | } catch {
95 | // Should not be the case, but ignore if no package.json is found
96 | }
97 |
98 | try {
99 | const projectMetadataPath = await findUp("package.json");
100 | this.configPath = await findUp(CONFIG_FILE_NAME);
101 | this.projectPath = dirname(
102 | this.configPath ?? projectMetadataPath ?? process.cwd(),
103 | );
104 |
105 | if (!this.configPath) return;
106 |
107 | const content = await readFile(this.configPath, "utf-8");
108 | const parsed = parseJSON(content) as unknown as Partial<Config>;
109 |
110 | // Normalize values
111 | if (parsed.baseUrl)
112 | parsed.baseUrl = stripTrailingSlash(parsed.baseUrl.trim());
113 | if (parsed.apiUrl)
114 | parsed.apiUrl = stripTrailingSlash(parsed.apiUrl.trim());
115 | if (parsed.typesOutput?.length)
116 | parsed.typesOutput = normalizeTypesOutput(parsed.typesOutput);
117 |
118 | if (!this.validateConfig!(parsed)) {
119 | handleError(
120 | new ConfigValidationError(this.validateConfig!.errors),
121 | "Config",
122 | );
123 | }
124 |
125 | this.config = assignJSON(this.config, parsed);
126 | } catch {
127 | // No config file found
128 | }
129 | }
130 |
131 | /**
132 | * Create a new config file with initial values.
133 | * @param overwrite If true, overwrites existing config file. Defaults to false
134 | */
135 | async saveConfigFile(overwrite = false) {
136 | const configWithoutDefaults: Partial<Config> = assignJSON({}, this.config);
137 |
138 | // Only include non-default values and $schema
139 | for (const untypedKey in configWithoutDefaults) {
140 | const key = untypedKey as keyof Config;
141 | if (
142 | !["$schema"].includes(key) &&
143 | equal(configWithoutDefaults[key], defaultConfig[key])
144 | ) {
145 | delete configWithoutDefaults[key];
146 | }
147 | }
148 |
149 | const configJSON = stringifyJSON(configWithoutDefaults, null, 2);
150 |
151 | if (this.configPath && !overwrite) {
152 | throw new Error("Config file already exists");
153 | }
154 |
155 | if (this.configPath) {
156 | await writeFile(this.configPath, configJSON);
157 | } else {
158 | // Write to the project path
159 | const packageJSONPath = await findUp("package.json");
160 | this.projectPath = dirname(packageJSONPath ?? process.cwd());
161 | this.configPath = join(this.projectPath, CONFIG_FILE_NAME);
162 | await writeFile(this.configPath, configJSON);
163 | }
164 | }
165 |
166 | getConfig(): Config;
167 | getConfig<K extends keyof Config>(key: K): Config[K];
168 | getConfig<K extends keyof Config>(key?: K) {
169 | return key ? this.config?.[key] : this.config;
170 | }
171 |
172 | getConfigPath() {
173 | return this.configPath;
174 | }
175 |
176 | getClientVersion() {
177 | return this.clientVersion;
178 | }
179 |
180 | getProjectPath() {
181 | return this.projectPath ?? process.cwd();
182 | }
183 |
184 | setConfig(newConfig: Partial<Config>) {
185 | // Update the config with new values skipping undefined values
186 | for (const untypedKey in newConfig) {
187 | const key = untypedKey as keyof Config;
188 | if (newConfig[key] === undefined) continue;
189 | (this.config as any)[key] = newConfig[key];
190 | }
191 | }
192 | }
193 |
194 | export const configStore = new ConfigStore();
195 |
```
--------------------------------------------------------------------------------
/packages/openfeature-browser-provider/src/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | import {
2 | ErrorCode,
3 | EvaluationContext,
4 | JsonValue,
5 | OpenFeatureEventEmitter,
6 | Provider,
7 | ProviderMetadata,
8 | ProviderStatus,
9 | ResolutionDetails,
10 | StandardResolutionReasons,
11 | TrackingEventDetails,
12 | } from "@openfeature/web-sdk";
13 |
14 | import { Flag, InitOptions, ReflagClient } from "@reflag/browser-sdk";
15 |
16 | export type ContextTranslationFn = (
17 | context?: EvaluationContext,
18 | ) => Record<string, any>;
19 |
20 | export function defaultContextTranslator(
21 | context?: EvaluationContext,
22 | ): Record<string, any> {
23 | if (!context) return {};
24 | return {
25 | user: {
26 | id: context.targetingKey ?? context["userId"]?.toString(),
27 | email: context["email"]?.toString(),
28 | name: context["name"]?.toString(),
29 | avatar: context["avatar"]?.toString(),
30 | country: context["country"]?.toString(),
31 | },
32 | company: {
33 | id: context["companyId"]?.toString(),
34 | name: context["companyName"]?.toString(),
35 | plan: context["companyPlan"]?.toString(),
36 | avatar: context["companyAvatar"]?.toString(),
37 | },
38 | };
39 | }
40 |
41 | export class ReflagBrowserSDKProvider implements Provider {
42 | readonly metadata: ProviderMetadata = {
43 | name: "reflag-browser-provider",
44 | };
45 |
46 | private _client?: ReflagClient;
47 |
48 | private readonly _clientOptions: InitOptions;
49 | private readonly _contextTranslator: ContextTranslationFn;
50 |
51 | public events = new OpenFeatureEventEmitter();
52 |
53 | private _status: ProviderStatus = ProviderStatus.NOT_READY;
54 |
55 | set status(status: ProviderStatus) {
56 | this._status = status;
57 | }
58 |
59 | get status() {
60 | return this._status;
61 | }
62 |
63 | get client() {
64 | return this._client;
65 | }
66 |
67 | constructor({
68 | contextTranslator,
69 | ...opts
70 | }: InitOptions & { contextTranslator?: ContextTranslationFn }) {
71 | this._clientOptions = opts;
72 | this._contextTranslator = contextTranslator || defaultContextTranslator;
73 | }
74 |
75 | async initialize(context?: EvaluationContext): Promise<void> {
76 | const client = new ReflagClient({
77 | ...this._clientOptions,
78 | ...this._contextTranslator(context),
79 | });
80 |
81 | try {
82 | await client.initialize();
83 | this.status = ProviderStatus.READY;
84 | this._client = client;
85 | } catch {
86 | this.status = ProviderStatus.ERROR;
87 | }
88 | }
89 |
90 | onClose(): Promise<void> {
91 | if (this._client) {
92 | return this._client?.stop();
93 | }
94 | return Promise.resolve();
95 | }
96 |
97 | async onContextChange(
98 | _oldContext: EvaluationContext,
99 | newContext: EvaluationContext,
100 | ): Promise<void> {
101 | await this.initialize(newContext);
102 | }
103 |
104 | private resolveFlag<T extends JsonValue>(
105 | flagKey: string,
106 | defaultValue: T,
107 | resolveFn: (feature: Flag) => ResolutionDetails<T>,
108 | ): ResolutionDetails<T> {
109 | if (!this._client) {
110 | return {
111 | value: defaultValue,
112 | reason: StandardResolutionReasons.DEFAULT,
113 | errorCode: ErrorCode.PROVIDER_NOT_READY,
114 | errorMessage: "Reflag client not initialized",
115 | } satisfies ResolutionDetails<T>;
116 | }
117 |
118 | const features = this._client.getFlags();
119 | if (flagKey in features) {
120 | return resolveFn(this._client.getFlag(flagKey));
121 | }
122 |
123 | return {
124 | value: defaultValue,
125 | reason: StandardResolutionReasons.DEFAULT,
126 | errorCode: ErrorCode.FLAG_NOT_FOUND,
127 | errorMessage: `Flag ${flagKey} not found`,
128 | };
129 | }
130 |
131 | resolveBooleanEvaluation(flagKey: string, defaultValue: boolean) {
132 | return this.resolveFlag(flagKey, defaultValue, (feature) => {
133 | return {
134 | value: feature.isEnabled,
135 | variant: feature.config.key,
136 | reason: StandardResolutionReasons.TARGETING_MATCH,
137 | };
138 | });
139 | }
140 |
141 | resolveNumberEvaluation(_flagKey: string, defaultValue: number) {
142 | return {
143 | value: defaultValue,
144 | reason: StandardResolutionReasons.ERROR,
145 | errorCode: ErrorCode.GENERAL,
146 | errorMessage:
147 | "Reflag doesn't support this method. Use `resolveObjectEvaluation` instead.",
148 | };
149 | }
150 |
151 | resolveStringEvaluation(
152 | flagKey: string,
153 | defaultValue: string,
154 | ): ResolutionDetails<string> {
155 | return this.resolveFlag(flagKey, defaultValue, (feature) => {
156 | if (!feature.config.key) {
157 | return {
158 | value: defaultValue,
159 | reason: StandardResolutionReasons.DEFAULT,
160 | };
161 | }
162 |
163 | return {
164 | value: feature.config.key as string,
165 | variant: feature.config.key,
166 | reason: StandardResolutionReasons.TARGETING_MATCH,
167 | };
168 | });
169 | }
170 |
171 | resolveObjectEvaluation<T extends JsonValue>(
172 | flagKey: string,
173 | defaultValue: T,
174 | ) {
175 | return this.resolveFlag(flagKey, defaultValue, (feature) => {
176 | const expType = typeof defaultValue;
177 |
178 | const payloadType = typeof feature.config.payload;
179 |
180 | if (
181 | feature.config.payload === undefined ||
182 | feature.config.payload === null ||
183 | payloadType !== expType
184 | ) {
185 | return {
186 | value: defaultValue,
187 | reason: StandardResolutionReasons.ERROR,
188 | variant: feature.config.key,
189 | errorCode: ErrorCode.TYPE_MISMATCH,
190 | errorMessage: `Expected remote config payload of type \`${expType}\` but got \`${payloadType}\`.`,
191 | };
192 | }
193 |
194 | return {
195 | value: feature.config.payload,
196 | variant: feature.config.key,
197 | reason: StandardResolutionReasons.TARGETING_MATCH,
198 | };
199 | });
200 | }
201 |
202 | track(
203 | trackingEventName: string,
204 | _context?: EvaluationContext,
205 | trackingEventDetails?: TrackingEventDetails,
206 | ): void {
207 | if (!this._client) {
208 | this._clientOptions.logger?.error("client not initialized");
209 | }
210 |
211 | this._client
212 | ?.track(trackingEventName, trackingEventDetails)
213 | .catch((e: any) => {
214 | this._clientOptions.logger?.error("error tracking event", e);
215 | });
216 | }
217 | }
218 |
```
--------------------------------------------------------------------------------
/packages/node-sdk/src/utils.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { createHash, Hash } from "crypto";
2 |
3 | import { IdType, Logger, LogLevel } from "./types";
4 |
5 | /**
6 | * Assert that the given condition is `true`.
7 | *
8 | * @param condition - The condition to check.
9 | * @param message - The error message to throw.
10 | **/
11 | export function ok(condition: boolean, message: string): asserts condition {
12 | if (!condition) {
13 | throw new Error(`validation failed: ${message}`);
14 | }
15 | }
16 | /**
17 | * Assert that the given values is a valid user/company id
18 | **/
19 | export function idOk(id: IdType, entity: string) {
20 | ok(
21 | (typeof id === "string" && id.length > 0) || typeof id === "number",
22 | `${entity} must be a string or number if given`,
23 | );
24 | }
25 |
26 | /**
27 | * Check if the given item is an object.
28 | *
29 | * @param item - The item to check.
30 | * @returns `true` if the item is an object, `false` otherwise.
31 | **/
32 | export function isObject(item: any): item is Record<string, any> {
33 | return (item && typeof item === "object" && !Array.isArray(item)) || false;
34 | }
35 |
36 | export type LogFn = (message: string, ...args: any[]) => void;
37 | export function decorate(prefix: string, fn: LogFn): LogFn {
38 | return (message: string, ...args: any[]) => {
39 | fn(`${prefix} ${message}`, ...args);
40 | };
41 | }
42 |
43 | export function applyLogLevel(logger: Logger, logLevel: LogLevel) {
44 | switch (logLevel?.toLocaleUpperCase()) {
45 | case "DEBUG":
46 | return {
47 | debug: decorate("[debug]", logger.debug),
48 | info: decorate("[info]", logger.info),
49 | warn: decorate("[warn]", logger.warn),
50 | error: decorate("[error]", logger.error),
51 | };
52 | case "INFO":
53 | return {
54 | debug: () => void 0,
55 | info: decorate("[info]", logger.info),
56 | warn: decorate("[warn]", logger.warn),
57 | error: decorate("[error]", logger.error),
58 | };
59 | case "WARN":
60 | return {
61 | debug: () => void 0,
62 | info: () => void 0,
63 | warn: decorate("[warn]", logger.warn),
64 | error: decorate("[error]", logger.error),
65 | };
66 | case "ERROR":
67 | return {
68 | debug: () => void 0,
69 | info: () => void 0,
70 | warn: () => void 0,
71 | error: decorate("[error]", logger.error),
72 | };
73 | default:
74 | throw new Error(`invalid log level: ${logLevel}`);
75 | }
76 | }
77 |
78 | /**
79 | * Decorate the messages of a given logger with the given prefix.
80 | *
81 | * @param prefix - The prefix to add to log messages.
82 | * @param logger - The logger to decorate.
83 | * @returns The decorated logger.
84 | **/
85 | export function decorateLogger(prefix: string, logger: Logger): Logger {
86 | ok(typeof prefix === "string", "prefix must be a string");
87 | ok(typeof logger === "object", "logger must be an object");
88 |
89 | return {
90 | debug: decorate(prefix, logger.debug),
91 | info: decorate(prefix, logger.info),
92 | warn: decorate(prefix, logger.warn),
93 | error: decorate(prefix, logger.error),
94 | };
95 | }
96 |
97 | /** Merge two objects, skipping `undefined` values.
98 | *
99 | * @param target - The target object.
100 | * @param source - The source object.
101 | * @returns The merged object.
102 | **/
103 | export function mergeSkipUndefined<T extends object, U extends object>(
104 | target: T,
105 | source: U,
106 | ): T & U {
107 | const newTarget = { ...target };
108 | for (const key in source) {
109 | if (source[key] === undefined) {
110 | continue;
111 | }
112 | (newTarget as any)[key] = source[key];
113 | }
114 | return newTarget as T & U;
115 | }
116 |
117 | function updateSha1Hash(hash: Hash, value: any) {
118 | if (value === null) {
119 | hash.update("null");
120 | } else {
121 | switch (typeof value) {
122 | case "object":
123 | if (Array.isArray(value)) {
124 | for (const item of value) {
125 | updateSha1Hash(hash, item);
126 | }
127 | } else {
128 | for (const key of Object.keys(value).sort()) {
129 | hash.update(key);
130 | updateSha1Hash(hash, value[key]);
131 | }
132 | }
133 | break;
134 | case "string":
135 | hash.update(value);
136 | break;
137 | case "number":
138 | case "boolean":
139 | case "symbol":
140 | case "bigint":
141 | case "function":
142 | hash.update(value.toString());
143 | break;
144 | case "undefined":
145 | default:
146 | break;
147 | }
148 | }
149 | }
150 |
151 | /** Hash an object using SHA1.
152 | *
153 | * @param obj - The object to hash.
154 | *
155 | * @returns The SHA1 hash of the object.
156 | **/
157 | export function hashObject(obj: Record<string, any>): string {
158 | ok(isObject(obj), "obj must be an object");
159 |
160 | const hash = createHash("sha1");
161 | updateSha1Hash(hash, obj);
162 |
163 | return hash.digest("base64");
164 | }
165 |
166 | export function once<T extends () => ReturnType<T>>(
167 | fn: T,
168 | ): () => ReturnType<T> {
169 | let called = false;
170 | let returned: ReturnType<T> | undefined;
171 | return function (): ReturnType<T> {
172 | if (called) {
173 | return returned!;
174 | }
175 | returned = fn();
176 | called = true;
177 | return returned;
178 | };
179 | }
180 |
181 | export class TimeoutError extends Error {
182 | constructor(timeoutMs: number) {
183 | super(`Operation timed out after ${timeoutMs}ms`);
184 | this.name = "TimeoutError";
185 | }
186 | }
187 |
188 | /**
189 | * Wraps a promise with a timeout. If the promise doesn't resolve within the specified
190 | * timeout, it will reject with a timeout error. The original promise will still
191 | * continue to execute but its result will be ignored.
192 | *
193 | * @param promise - The promise to wrap with a timeout
194 | * @param timeoutMs - The timeout in milliseconds
195 | * @returns A promise that resolves with the original promise result or rejects with a timeout error
196 | * @throws {Error} If the timeout is reached before the promise resolves
197 | **/
198 | export function withTimeout<T>(
199 | promise: Promise<T>,
200 | timeoutMs: number,
201 | ): Promise<T> {
202 | ok(timeoutMs > 0, "timeout must be a positive number");
203 |
204 | return new Promise((resolve, reject) => {
205 | const timeoutId = setTimeout(() => {
206 | reject(new TimeoutError(timeoutMs));
207 | }, timeoutMs);
208 |
209 | promise
210 | .then((result) => {
211 | resolve(result);
212 | })
213 | .catch((error) => {
214 | reject(error);
215 | })
216 | .finally(() => {
217 | clearTimeout(timeoutId);
218 | });
219 | });
220 | }
221 |
```
--------------------------------------------------------------------------------
/packages/cli/commands/flags.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { input } 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, getApp, getOrg } from "../services/bootstrap.js";
8 | import { createFlag, Flag, listFlags } from "../services/flags.js";
9 | import { configStore } from "../stores/config.js";
10 | import {
11 | handleError,
12 | MissingAppIdError,
13 | MissingEnvIdError,
14 | } from "../utils/errors.js";
15 | import {
16 | genFlagKey,
17 | genTypes,
18 | indentLines,
19 | KeyFormatPatterns,
20 | writeTypesToFile,
21 | } from "../utils/gen.js";
22 | import {
23 | appIdOption,
24 | flagKeyOption,
25 | flagNameArgument,
26 | typesFormatOption,
27 | typesOutOption,
28 | } from "../utils/options.js";
29 | import { baseUrlSuffix, featureUrl } from "../utils/urls.js";
30 |
31 | type CreateFlagOptions = {
32 | key?: string;
33 | };
34 |
35 | export const createFlagAction = async (
36 | name: string | undefined,
37 | { key }: CreateFlagOptions,
38 | ) => {
39 | const { baseUrl, appId } = configStore.getConfig();
40 | let spinner: Ora | undefined;
41 |
42 | if (!appId) {
43 | handleError(new MissingAppIdError(), "Flags Create");
44 | }
45 |
46 | let app: App;
47 | try {
48 | app = getApp(appId);
49 | } catch (error) {
50 | handleError(error, "Flags Create");
51 | }
52 |
53 | const production = app.environments.find((e) => e.isProduction);
54 |
55 | try {
56 | const org = getOrg();
57 | console.log(
58 | `Creating flag for app ${chalk.cyan(app.name)}${baseUrlSuffix(baseUrl)}.`,
59 | );
60 |
61 | if (!name) {
62 | name = await input({
63 | message: "New flag name:",
64 | validate: (text) => text.length > 0 || "Name is required.",
65 | });
66 | }
67 |
68 | if (!key) {
69 | const keyFormat = org.featureKeyFormat;
70 | const keyValidator = KeyFormatPatterns[keyFormat];
71 | key = await input({
72 | message: "New flag key:",
73 | default: genFlagKey(name, keyFormat),
74 | validate: (str) => keyValidator.regex.test(str) || keyValidator.message,
75 | });
76 | }
77 |
78 | spinner = ora(`Creating flag...`).start();
79 | const flag = await createFlag(appId, { name, key });
80 |
81 | spinner.succeed(
82 | `Created flag ${chalk.cyan(flag.name)} with key ${chalk.cyan(flag.key)}:`,
83 | );
84 | if (production) {
85 | console.log(
86 | indentLines(chalk.magenta(featureUrl(baseUrl, production, flag))),
87 | );
88 | }
89 | } catch (error) {
90 | spinner?.fail("Flag creation failed.");
91 | handleError(error, "Flags Create");
92 | }
93 | };
94 |
95 | export const listFlagsAction = async () => {
96 | const { baseUrl, appId } = configStore.getConfig();
97 | let spinner: Ora | undefined;
98 |
99 | if (!appId) {
100 | handleError(new MissingAppIdError(), "Flags Create");
101 | }
102 |
103 | try {
104 | const app = getApp(appId);
105 | const production = app.environments.find((e) => e.isProduction);
106 | if (!production) {
107 | handleError(new MissingEnvIdError(), "Flags Types");
108 | }
109 |
110 | spinner = ora(
111 | `Loading flags of app ${chalk.cyan(app.name)}${baseUrlSuffix(baseUrl)}...`,
112 | ).start();
113 |
114 | const flagsResponse = await listFlags(appId, {
115 | envId: production.id,
116 | });
117 |
118 | spinner.succeed(
119 | `Loaded flags of app ${chalk.cyan(app.name)}${baseUrlSuffix(baseUrl)}.`,
120 | );
121 |
122 | console.table(
123 | flagsResponse.data.map(({ key, name, stage }) => ({
124 | name,
125 | key,
126 | stage: stage?.name,
127 | })),
128 | );
129 | } catch (error) {
130 | spinner?.fail("Loading flags failed.");
131 | handleError(error, "Flags List");
132 | }
133 | };
134 |
135 | export const generateTypesAction = async () => {
136 | const { baseUrl, appId } = configStore.getConfig();
137 | const typesOutput = configStore.getConfig("typesOutput");
138 |
139 | let spinner: Ora | undefined;
140 | let flags: Flag[] = [];
141 |
142 | if (!appId) {
143 | handleError(new MissingAppIdError(), "Flags Types");
144 | }
145 |
146 | let app: App;
147 | try {
148 | app = getApp(appId);
149 | } catch (error) {
150 | handleError(error, "Flags Types");
151 | }
152 |
153 | const production = app.environments.find((e) => e.isProduction);
154 | if (!production) {
155 | handleError(new MissingEnvIdError(), "Flags Types");
156 | }
157 |
158 | try {
159 | spinner = ora(
160 | `Loading flags of app ${chalk.cyan(app.name)}${baseUrlSuffix(baseUrl)}...`,
161 | ).start();
162 |
163 | flags = await listFlags(appId, {
164 | envId: production.id,
165 | includeRemoteConfigs: true,
166 | }).then((res) => res.data);
167 |
168 | spinner.succeed(
169 | `Loaded flags of app ${chalk.cyan(app.name)}${baseUrlSuffix(baseUrl)}.`,
170 | );
171 | } catch (error) {
172 | spinner?.fail("Loading flags failed.");
173 | handleError(error, "Flags Types");
174 | }
175 |
176 | try {
177 | spinner = ora(`Generating flag types...`).start();
178 | const projectPath = configStore.getProjectPath();
179 |
180 | // Generate types for each output configuration
181 | for (const output of typesOutput) {
182 | const types = genTypes(flags, output.format);
183 |
184 | const outPath = await writeTypesToFile(types, output.path, projectPath);
185 | spinner.succeed(
186 | `Generated ${output.format} types in ${chalk.cyan(relative(projectPath, outPath))}.`,
187 | );
188 | }
189 | } catch (error) {
190 | spinner?.fail("Type generation failed.");
191 | handleError(error, "Flags Types");
192 | }
193 | };
194 |
195 | export function registerFlagCommands(cli: Command) {
196 | const flagsCommand = new Command("flags").description("Manage flags.");
197 |
198 | flagsCommand
199 | .command("create")
200 | .description("Create a new flag.")
201 | .addOption(appIdOption)
202 | .addOption(flagKeyOption)
203 | .addArgument(flagNameArgument)
204 | .action(createFlagAction);
205 |
206 | flagsCommand
207 | .command("list")
208 | .alias("ls")
209 | .description("List all flags.")
210 | .addOption(appIdOption)
211 | .action(listFlagsAction);
212 |
213 | flagsCommand
214 | .command("types")
215 | .description("Generate flag types.")
216 | .addOption(appIdOption)
217 | .addOption(typesOutOption)
218 | .addOption(typesFormatOption)
219 | .action(generateTypesAction);
220 |
221 | // Update the config with the cli override values
222 | flagsCommand.hook("preAction", (_, command) => {
223 | const { appId, out, format } = command.opts();
224 | configStore.setConfig({
225 | appId,
226 | typesOutput: out ? [{ path: out, format: format || "react" }] : undefined,
227 | });
228 | });
229 |
230 | cli.addCommand(flagsCommand);
231 | }
232 |
```
--------------------------------------------------------------------------------
/packages/cli/utils/json.ts:
--------------------------------------------------------------------------------
```typescript
1 | export type JSONPrimitive =
2 | | number
3 | | string
4 | | boolean
5 | | null
6 | | JSONPrimitive[]
7 | | { [key: string]: JSONPrimitive };
8 |
9 | export type PrimitiveAST = { kind: "primitive"; type: string };
10 | export type ArrayAST = { kind: "array"; elementType: TypeAST };
11 | export type ObjectAST = {
12 | kind: "object";
13 | properties: { key: string; type: TypeAST; optional: boolean }[];
14 | };
15 | export type UnionAST = { kind: "union"; types: TypeAST[] };
16 |
17 | // Type AST to represent TypeScript types
18 | export type TypeAST = PrimitiveAST | ArrayAST | ObjectAST | UnionAST;
19 |
20 | // Convert JSON value to TypeAST
21 | export function toTypeAST(value: JSONPrimitive, path: string[] = []): TypeAST {
22 | if (value === null) return { kind: "primitive", type: "null" };
23 |
24 | if (Array.isArray(value)) {
25 | if (value.length === 0) {
26 | return {
27 | kind: "array",
28 | elementType: { kind: "primitive", type: "any" },
29 | };
30 | }
31 |
32 | // Process all elements in the array instead of just the first one
33 | const elementTypes = value.map((item, index) =>
34 | toTypeAST(item, [...path, index.toString()]),
35 | );
36 |
37 | return {
38 | kind: "array",
39 | elementType: mergeTypeASTs(elementTypes),
40 | };
41 | }
42 |
43 | if (typeof value === "object") {
44 | return {
45 | kind: "object",
46 | properties: Object.entries(value).map(([key, val]) => ({
47 | key,
48 | type: toTypeAST(val, [...path, key]),
49 | optional: false,
50 | })),
51 | };
52 | }
53 |
54 | return { kind: "primitive", type: typeof value };
55 | }
56 |
57 | // Merge multiple TypeASTs into one
58 | export function mergeTypeASTs(types: TypeAST[]): TypeAST {
59 | if (types.length === 0) return { kind: "primitive", type: "any" };
60 | if (types.length === 1) return types[0];
61 |
62 | // Group ASTs by kind
63 | const byKind = {
64 | union: types.filter((t) => t.kind === "union"),
65 | primitive: types.filter((t) => t.kind === "primitive"),
66 | array: types.filter((t) => t.kind === "array"),
67 | object: types.filter((t) => t.kind === "object"),
68 | };
69 |
70 | // Create a union for mixed kinds
71 | const hasMixedKinds =
72 | byKind.union.length > 0 || // If we have any unions, treat it as mixed kinds
73 | (byKind.primitive.length > 0 &&
74 | (byKind.array.length > 0 || byKind.object.length > 0)) ||
75 | (byKind.array.length > 0 && byKind.object.length > 0);
76 |
77 | if (hasMixedKinds) {
78 | // If there are existing unions, flatten them into the current union
79 | if (byKind.union.length > 0) {
80 | // Flatten existing unions and collect types by category
81 | const flattenedTypes: TypeAST[] = [];
82 | const objectsToMerge: ObjectAST[] = [...byKind.object];
83 | const arraysToMerge: ArrayAST[] = [...byKind.array];
84 |
85 | // Add primitives directly
86 | flattenedTypes.push(...byKind.primitive);
87 |
88 | // Process union types
89 | for (const unionType of byKind.union) {
90 | for (const type of unionType.types) {
91 | if (type.kind === "object") {
92 | objectsToMerge.push(type);
93 | } else if (type.kind === "array") {
94 | arraysToMerge.push(type);
95 | } else {
96 | flattenedTypes.push(type);
97 | }
98 | }
99 | }
100 |
101 | // Merge objects and arrays if they exist
102 | if (objectsToMerge.length > 0) {
103 | flattenedTypes.push(mergeTypeASTs(objectsToMerge));
104 | }
105 |
106 | if (arraysToMerge.length > 0) {
107 | flattenedTypes.push(mergeTypeASTs(arraysToMerge));
108 | }
109 |
110 | return { kind: "union", types: flattenedTypes };
111 | }
112 |
113 | return { kind: "union", types };
114 | }
115 |
116 | // Handle primitives
117 | if (byKind.primitive.length === types.length) {
118 | const uniqueTypes = [...new Set(byKind.primitive.map((p) => p.type))];
119 | return uniqueTypes.length === 1
120 | ? { kind: "primitive", type: uniqueTypes[0] }
121 | : {
122 | kind: "union",
123 | types: uniqueTypes.map((type) => ({ kind: "primitive", type })),
124 | };
125 | }
126 |
127 | // Merge arrays
128 | if (byKind.array.length === types.length) {
129 | return {
130 | kind: "array",
131 | elementType: mergeTypeASTs(byKind.array.map((a) => a.elementType)),
132 | };
133 | }
134 |
135 | // Merge objects
136 | if (byKind.object.length === types.length) {
137 | // Get all unique property keys
138 | const allKeys = [
139 | ...new Set(
140 | byKind.object.flatMap((obj) => obj.properties.map((p) => p.key)),
141 | ),
142 | ];
143 |
144 | // Merge properties with same keys
145 | const mergedProperties = allKeys.map((key) => {
146 | const props = byKind.object
147 | .map((obj) => obj.properties.find((p) => p.key === key))
148 | .filter((obj) => !!obj);
149 |
150 | return {
151 | key,
152 | type: mergeTypeASTs(props.map((p) => p.type)),
153 | optional: byKind.object.some(
154 | (obj) => !obj.properties.some((p) => p.key === key),
155 | ),
156 | };
157 | });
158 |
159 | return { kind: "object", properties: mergedProperties };
160 | }
161 |
162 | // Fallback
163 | return { kind: "primitive", type: "any" };
164 | }
165 |
166 | // Stringify TypeAST to TypeScript type declaration
167 | export function stringifyTypeAST(ast: TypeAST, nestLevel = 0): string {
168 | const indent = " ".repeat(nestLevel * 2);
169 | const nextIndent = " ".repeat((nestLevel + 1) * 2);
170 |
171 | switch (ast.kind) {
172 | case "primitive":
173 | return ast.type;
174 |
175 | case "array":
176 | return `(${stringifyTypeAST(ast.elementType, nestLevel)})[]`;
177 |
178 | case "object":
179 | if (ast.properties.length === 0) return "{}";
180 |
181 | return `{\n${ast.properties
182 | .map(({ key, optional, type }) => {
183 | return `${nextIndent}${quoteKey(key)}${optional ? "?" : ""}: ${stringifyTypeAST(
184 | type,
185 | nestLevel + 1,
186 | )}`;
187 | })
188 | .join(",\n")}\n${indent}}`;
189 |
190 | case "union":
191 | if (ast.types.length === 0) return "any";
192 | if (ast.types.length === 1)
193 | return stringifyTypeAST(ast.types[0], nestLevel);
194 | return ast.types
195 | .map((type) => stringifyTypeAST(type, nestLevel))
196 | .join(" | ");
197 | }
198 | }
199 |
200 | export function quoteKey(key: string): string {
201 | return /[^a-zA-Z0-9_]/.test(key) || /^[0-9]/.test(key) ? `"${key}"` : key;
202 | }
203 |
204 | // Convert JSON array to TypeScript type
205 | export function JSONToType(json: JSONPrimitive[]): string | null {
206 | if (!json.length) return null;
207 |
208 | return stringifyTypeAST(mergeTypeASTs(json.map((item) => toTypeAST(item))));
209 | }
210 |
```
--------------------------------------------------------------------------------
/packages/node-sdk/test/batch-buffer.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 BatchBuffer from "../src/batch-buffer";
12 | import { BATCH_INTERVAL_MS, BATCH_MAX_SIZE } from "../src/config";
13 | import { Logger } from "../src/types";
14 |
15 | describe("BatchBuffer", () => {
16 | const mockFlushHandler = vi.fn();
17 |
18 | const mockLogger: Logger = {
19 | debug: vi.fn(),
20 | info: vi.fn(),
21 | warn: vi.fn(),
22 | error: vi.fn(),
23 | };
24 |
25 | beforeEach(() => {
26 | vi.clearAllMocks();
27 | });
28 |
29 | describe("constructor", () => {
30 | it("should throw an error if options are invalid", () => {
31 | expect(() => new BatchBuffer(null as any)).toThrow(
32 | "options must be an object",
33 | );
34 |
35 | expect(() => new BatchBuffer("bad" as any)).toThrow(
36 | "options must be an object",
37 | );
38 |
39 | expect(
40 | () => new BatchBuffer({ flushHandler: null as any } as any),
41 | ).toThrow("flushHandler must be a function");
42 |
43 | expect(
44 | () => new BatchBuffer({ flushHandler: "not a function" } as any),
45 | ).toThrow("flushHandler must be a function");
46 |
47 | expect(
48 | () =>
49 | new BatchBuffer({
50 | flushHandler: mockFlushHandler,
51 | logger: "string",
52 | } as any),
53 | ).toThrow("logger must be an object");
54 |
55 | expect(
56 | () =>
57 | new BatchBuffer({
58 | flushHandler: mockFlushHandler,
59 | maxSize: -1,
60 | } as any),
61 | ).toThrow("maxSize must be greater than 0");
62 | });
63 |
64 | it("should initialize with specified values", () => {
65 | const buffer = new BatchBuffer({
66 | flushHandler: mockFlushHandler,
67 | maxSize: 22,
68 | intervalMs: 33,
69 | });
70 |
71 | expect(buffer).toEqual({
72 | buffer: [],
73 | flushHandler: mockFlushHandler,
74 | timer: null,
75 | intervalMs: 33,
76 | logger: undefined,
77 | maxSize: 22,
78 | });
79 | });
80 |
81 | it("should initialize with default values if not provided", () => {
82 | const buffer = new BatchBuffer({ flushHandler: mockFlushHandler });
83 | expect(buffer).toEqual({
84 | buffer: [],
85 | flushHandler: mockFlushHandler,
86 | intervalMs: BATCH_INTERVAL_MS,
87 | maxSize: BATCH_MAX_SIZE,
88 | timer: null,
89 | });
90 | });
91 | });
92 |
93 | describe("add", () => {
94 | it("should add item to the buffer and flush immediately if maxSize is reached", async () => {
95 | const buffer = new BatchBuffer({
96 | flushHandler: mockFlushHandler,
97 | maxSize: 1,
98 | });
99 |
100 | await buffer.add("item1");
101 |
102 | expect(mockFlushHandler).toHaveBeenCalledWith(["item1"]);
103 | expect(mockFlushHandler).toHaveBeenCalledTimes(1);
104 | });
105 |
106 | it("should set a flush timer if buffer does not reach maxSize", async () => {
107 | vi.useFakeTimers();
108 |
109 | const buffer = new BatchBuffer({
110 | flushHandler: mockFlushHandler,
111 | maxSize: 2,
112 | intervalMs: 1000,
113 | });
114 |
115 | await buffer.add("item1");
116 | expect(mockFlushHandler).not.toHaveBeenCalled();
117 |
118 | vi.advanceTimersByTime(1000);
119 | expect(mockFlushHandler).toHaveBeenCalledWith(["item1"]);
120 | expect(mockFlushHandler).toHaveBeenCalledTimes(1);
121 |
122 | vi.useRealTimers();
123 | });
124 | });
125 |
126 | describe("flush", () => {
127 | it("should not do anything if there are no items to flush", async () => {
128 | const buffer = new BatchBuffer({
129 | flushHandler: mockFlushHandler,
130 | logger: mockLogger,
131 | });
132 |
133 | await buffer.flush();
134 |
135 | expect(mockFlushHandler).not.toHaveBeenCalled();
136 |
137 | expect(mockLogger.debug).toHaveBeenCalledWith(
138 | "buffer is empty. nothing to flush",
139 | );
140 | });
141 |
142 | it("calling flush simultaneously should only flush data once", async () => {
143 | let itemsFlushed = 0;
144 | const buffer = new BatchBuffer({
145 | flushHandler: async (items) => {
146 | itemsFlushed += items.length;
147 | await new Promise((resolve) => setTimeout(resolve, 100));
148 | mockFlushHandler();
149 | },
150 | logger: mockLogger,
151 | });
152 |
153 | await buffer.add("item1");
154 | await Promise.all([buffer.flush(), buffer.flush()]);
155 | expect(itemsFlushed).toBe(1);
156 | });
157 |
158 | it("should flush buffer", async () => {
159 | const buffer = new BatchBuffer({
160 | flushHandler: mockFlushHandler,
161 | logger: mockLogger,
162 | });
163 |
164 | await buffer.add("item1");
165 | await buffer.flush();
166 |
167 | expect(mockFlushHandler).toHaveBeenCalledWith(["item1"]);
168 | await buffer.flush();
169 |
170 | expect(mockFlushHandler).toHaveBeenCalledTimes(1);
171 | });
172 |
173 | it("should log correctly during flush", async () => {
174 | const buffer = new BatchBuffer({
175 | flushHandler: mockFlushHandler,
176 | logger: mockLogger,
177 | });
178 |
179 | await buffer.add("item1");
180 | await buffer.flush();
181 |
182 | expect(mockLogger.info).toHaveBeenCalledWith("flushed buffered items", {
183 | count: 1,
184 | });
185 | });
186 | });
187 |
188 | describe("timer logic", () => {
189 | beforeAll(() => {
190 | vi.useFakeTimers();
191 | });
192 |
193 | afterAll(() => {
194 | vi.useRealTimers();
195 | });
196 |
197 | beforeEach(() => {
198 | vi.clearAllTimers();
199 | mockFlushHandler.mockReset();
200 | });
201 |
202 | it("should start the normal timer when adding first item", async () => {
203 | const buffer = new BatchBuffer({
204 | flushHandler: mockFlushHandler,
205 | logger: mockLogger,
206 | intervalMs: 100,
207 | });
208 |
209 | expect(buffer["timer"]).toBeNull();
210 | await buffer.add("item1");
211 |
212 | expect(buffer["timer"]).toBeDefined();
213 |
214 | await vi.advanceTimersByTimeAsync(100);
215 | expect(mockFlushHandler).toHaveBeenCalledTimes(1);
216 |
217 | expect(buffer["timer"]).toBeNull();
218 |
219 | expect(mockLogger.info).toHaveBeenCalledWith("flushed buffered items", {
220 | count: 1,
221 | });
222 | });
223 |
224 | it("should stop the normal timer if flushed manually", async () => {
225 | const buffer = new BatchBuffer({
226 | flushHandler: mockFlushHandler,
227 | logger: mockLogger,
228 | intervalMs: 100,
229 | maxSize: 2,
230 | });
231 |
232 | await buffer.add("item1");
233 | await buffer.add("item2");
234 |
235 | expect(buffer["timer"]).toBeNull();
236 |
237 | expect(mockLogger.info).toHaveBeenCalledWith("flushed buffered items", {
238 | count: 2,
239 | });
240 | });
241 | });
242 | });
243 |
```
--------------------------------------------------------------------------------
/packages/openfeature-node-provider/src/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | import {
2 | ErrorCode,
3 | EvaluationContext,
4 | JsonValue,
5 | OpenFeatureEventEmitter,
6 | Paradigm,
7 | Provider,
8 | ResolutionDetails,
9 | ServerProviderStatus,
10 | StandardResolutionReasons,
11 | TrackingEventDetails,
12 | } from "@openfeature/server-sdk";
13 |
14 | import {
15 | ClientOptions,
16 | Context as ReflagContext,
17 | ReflagClient,
18 | } from "@reflag/node-sdk";
19 |
20 | type ProviderOptions = ClientOptions & {
21 | contextTranslator?: (context: EvaluationContext) => ReflagContext;
22 | };
23 |
24 | export const defaultContextTranslator = (
25 | context: EvaluationContext,
26 | ): ReflagContext => {
27 | const user = {
28 | id: context.targetingKey ?? context["userId"]?.toString(),
29 | name: context["name"]?.toString(),
30 | email: context["email"]?.toString(),
31 | avatar: context["avatar"]?.toString(),
32 | country: context["country"]?.toString(),
33 | };
34 |
35 | const company = {
36 | id: context["companyId"]?.toString(),
37 | name: context["companyName"]?.toString(),
38 | avatar: context["companyAvatar"]?.toString(),
39 | plan: context["companyPlan"]?.toString(),
40 | };
41 |
42 | return {
43 | user,
44 | company,
45 | };
46 | };
47 |
48 | export class ReflagNodeProvider implements Provider {
49 | public readonly events = new OpenFeatureEventEmitter();
50 |
51 | private _client: ReflagClient;
52 |
53 | private contextTranslator: (context: EvaluationContext) => ReflagContext;
54 |
55 | public runsOn: Paradigm = "server";
56 |
57 | public status: ServerProviderStatus = ServerProviderStatus.NOT_READY;
58 |
59 | public metadata = {
60 | name: "reflag-node",
61 | };
62 |
63 | get client() {
64 | return this._client;
65 | }
66 |
67 | constructor({ contextTranslator, ...opts }: ProviderOptions) {
68 | this._client = new ReflagClient(opts);
69 | this.contextTranslator = contextTranslator ?? defaultContextTranslator;
70 | }
71 |
72 | public async initialize(): Promise<void> {
73 | await this._client.initialize();
74 | this.status = ServerProviderStatus.READY;
75 | }
76 |
77 | private resolveFlag<T extends JsonValue>(
78 | flagKey: string,
79 | defaultValue: T,
80 | context: ReflagContext,
81 | resolveFn: (
82 | feature: ReturnType<typeof this._client.getFlag>,
83 | ) => Promise<ResolutionDetails<T>>,
84 | ): Promise<ResolutionDetails<T>> {
85 | if (this.status !== ServerProviderStatus.READY) {
86 | return Promise.resolve({
87 | value: defaultValue,
88 | reason: StandardResolutionReasons.ERROR,
89 | errorCode: ErrorCode.PROVIDER_NOT_READY,
90 | errorMessage: "Reflag client not initialized",
91 | });
92 | }
93 |
94 | if (!context.user?.id) {
95 | return Promise.resolve({
96 | value: defaultValue,
97 | reason: StandardResolutionReasons.ERROR,
98 | errorCode: ErrorCode.INVALID_CONTEXT,
99 | errorMessage: "At least a user ID is required",
100 | });
101 | }
102 |
103 | const featureDefs = this._client.getFlagDefinitions();
104 | if (featureDefs.some(({ key }) => key === flagKey)) {
105 | return resolveFn(this._client.getFlag(context, flagKey));
106 | }
107 |
108 | return Promise.resolve({
109 | value: defaultValue,
110 | reason: StandardResolutionReasons.ERROR,
111 | errorCode: ErrorCode.FLAG_NOT_FOUND,
112 | errorMessage: `Flag ${flagKey} not found`,
113 | });
114 | }
115 |
116 | resolveBooleanEvaluation(
117 | flagKey: string,
118 | defaultValue: boolean,
119 | context: EvaluationContext,
120 | ): Promise<ResolutionDetails<boolean>> {
121 | return this.resolveFlag(
122 | flagKey,
123 | defaultValue,
124 | this.contextTranslator(context),
125 | (feature) => {
126 | return Promise.resolve({
127 | value: feature.isEnabled,
128 | variant: feature.config?.key,
129 | reason: StandardResolutionReasons.TARGETING_MATCH,
130 | });
131 | },
132 | );
133 | }
134 |
135 | resolveStringEvaluation(
136 | flagKey: string,
137 | defaultValue: string,
138 | context: EvaluationContext,
139 | ): Promise<ResolutionDetails<string>> {
140 | return this.resolveFlag(
141 | flagKey,
142 | defaultValue,
143 | this.contextTranslator(context),
144 | (feature) => {
145 | if (!feature.config.key) {
146 | return Promise.resolve({
147 | value: defaultValue,
148 | reason: StandardResolutionReasons.DEFAULT,
149 | });
150 | }
151 |
152 | return Promise.resolve({
153 | value: feature.config.key as string,
154 | variant: feature.config.key,
155 | reason: StandardResolutionReasons.TARGETING_MATCH,
156 | });
157 | },
158 | );
159 | }
160 |
161 | resolveNumberEvaluation(
162 | _flagKey: string,
163 | defaultValue: number,
164 | ): Promise<ResolutionDetails<number>> {
165 | return Promise.resolve({
166 | value: defaultValue,
167 | reason: StandardResolutionReasons.ERROR,
168 | errorCode: ErrorCode.GENERAL,
169 | errorMessage:
170 | "Reflag doesn't support this method. Use `resolveObjectEvaluation` instead.",
171 | });
172 | }
173 |
174 | resolveObjectEvaluation<T extends JsonValue>(
175 | flagKey: string,
176 | defaultValue: T,
177 | context: EvaluationContext,
178 | ): Promise<ResolutionDetails<T>> {
179 | return this.resolveFlag(
180 | flagKey,
181 | defaultValue,
182 | this.contextTranslator(context),
183 | (feature) => {
184 | const expType = typeof defaultValue;
185 | const payloadType = typeof feature.config.payload;
186 |
187 | if (
188 | feature.config.payload === undefined ||
189 | feature.config.payload === null ||
190 | payloadType !== expType
191 | ) {
192 | return Promise.resolve({
193 | value: defaultValue,
194 | variant: feature.config.key,
195 | reason: StandardResolutionReasons.ERROR,
196 | errorCode: ErrorCode.TYPE_MISMATCH,
197 | errorMessage: `Expected remote config payload of type \`${expType}\` but got \`${payloadType}\`.`,
198 | });
199 | }
200 |
201 | return Promise.resolve({
202 | value: feature.config.payload,
203 | variant: feature.config.key,
204 | reason: StandardResolutionReasons.TARGETING_MATCH,
205 | });
206 | },
207 | );
208 | }
209 |
210 | track(
211 | trackingEventName: string,
212 | context?: EvaluationContext,
213 | trackingEventDetails?: TrackingEventDetails,
214 | ): void {
215 | const translatedContext = context
216 | ? this.contextTranslator(context)
217 | : undefined;
218 |
219 | const userId = translatedContext?.user?.id;
220 | if (!userId) {
221 | this._client.logger?.warn("No user ID provided for tracking event");
222 | return;
223 | }
224 |
225 | void this._client.track(String(userId), trackingEventName, {
226 | attributes: trackingEventDetails,
227 | companyId: translatedContext?.company?.id?.toString(),
228 | });
229 | }
230 |
231 | public async onClose(): Promise<void> {
232 | await this._client.flush();
233 | }
234 | }
235 |
```