This is page 3 of 7. Use http://codebase.md/bucketco/bucket-javascript-sdk?lines=false&page={x} to view the full context.
# Directory Structure
```
├── .editorconfig
├── .gitattributes
├── .github
│ └── workflows
│ ├── package-ci.yml
│ └── publish.yml
├── .gitignore
├── .nvmrc
├── .prettierignore
├── .vscode
│ ├── extensions.json
│ └── settings.json
├── .yarnrc.yml
├── docs.sh
├── lerna.json
├── LICENSE
├── package.json
├── packages
│ ├── browser-sdk
│ │ ├── .prettierignore
│ │ ├── eslint.config.js
│ │ ├── example
│ │ │ ├── feedback
│ │ │ │ ├── feedback.html
│ │ │ │ └── Feedback.jsx
│ │ │ └── typescript
│ │ │ ├── app.ts
│ │ │ └── index.html
│ │ ├── FEEDBACK.md
│ │ ├── index.html
│ │ ├── package.json
│ │ ├── playwright.config.ts
│ │ ├── postcss.config.js
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── client.ts
│ │ │ ├── config.ts
│ │ │ ├── context.ts
│ │ │ ├── feedback
│ │ │ │ ├── feedback.ts
│ │ │ │ ├── prompts.ts
│ │ │ │ ├── promptStorage.ts
│ │ │ │ └── ui
│ │ │ │ ├── Button.css
│ │ │ │ ├── Button.tsx
│ │ │ │ ├── config
│ │ │ │ │ └── defaultTranslations.tsx
│ │ │ │ ├── css.d.ts
│ │ │ │ ├── FeedbackDialog.css
│ │ │ │ ├── FeedbackDialog.tsx
│ │ │ │ ├── FeedbackForm.css
│ │ │ │ ├── FeedbackForm.tsx
│ │ │ │ ├── hooks
│ │ │ │ │ └── useTimer.ts
│ │ │ │ ├── index.css
│ │ │ │ ├── index.ts
│ │ │ │ ├── Plug.tsx
│ │ │ │ ├── RadialProgress.css
│ │ │ │ ├── RadialProgress.tsx
│ │ │ │ ├── StarRating.css
│ │ │ │ ├── StarRating.tsx
│ │ │ │ └── types.ts
│ │ │ ├── flag
│ │ │ │ ├── flagCache.ts
│ │ │ │ └── flags.ts
│ │ │ ├── hooksManager.ts
│ │ │ ├── httpClient.ts
│ │ │ ├── index.ts
│ │ │ ├── logger.ts
│ │ │ ├── rateLimiter.ts
│ │ │ ├── sse.ts
│ │ │ ├── toolbar
│ │ │ │ ├── Flags.css
│ │ │ │ ├── Flags.tsx
│ │ │ │ ├── index.css
│ │ │ │ ├── index.ts
│ │ │ │ ├── Switch.css
│ │ │ │ ├── Switch.tsx
│ │ │ │ ├── Toolbar.css
│ │ │ │ └── Toolbar.tsx
│ │ │ └── ui
│ │ │ ├── constants.ts
│ │ │ ├── Dialog.css
│ │ │ ├── Dialog.tsx
│ │ │ ├── icons
│ │ │ │ ├── Check.tsx
│ │ │ │ ├── CheckCircle.tsx
│ │ │ │ ├── Close.tsx
│ │ │ │ ├── Dissatisfied.tsx
│ │ │ │ ├── Logo.tsx
│ │ │ │ ├── Neutral.tsx
│ │ │ │ ├── Satisfied.tsx
│ │ │ │ ├── VeryDissatisfied.tsx
│ │ │ │ └── VerySatisfied.tsx
│ │ │ ├── packages
│ │ │ │ └── floating-ui-preact-dom
│ │ │ │ ├── arrow.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── README.md
│ │ │ │ ├── types.ts
│ │ │ │ ├── useFloating.ts
│ │ │ │ └── utils
│ │ │ │ ├── deepEqual.ts
│ │ │ │ ├── getDPR.ts
│ │ │ │ ├── roundByDPR.ts
│ │ │ │ └── useLatestRef.ts
│ │ │ ├── types.ts
│ │ │ └── utils.ts
│ │ ├── test
│ │ │ ├── client.test.ts
│ │ │ ├── e2e
│ │ │ │ ├── acceptance.browser.spec.ts
│ │ │ │ ├── empty.html
│ │ │ │ ├── feedback-widget.browser.spec.ts
│ │ │ │ └── give-feedback-button.html
│ │ │ ├── flagCache.test.ts
│ │ │ ├── flags.test.ts
│ │ │ ├── hooksManager.test.ts
│ │ │ ├── httpClient.test.ts
│ │ │ ├── init.test.ts
│ │ │ ├── mocks
│ │ │ │ ├── handlers.ts
│ │ │ │ └── server.ts
│ │ │ ├── prompts.test.ts
│ │ │ ├── promptStorage.test.ts
│ │ │ ├── rateLimiter.test.ts
│ │ │ ├── sse.test.ts
│ │ │ ├── testLogger.ts
│ │ │ └── usage.test.ts
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ ├── typedoc.json
│ │ ├── vite.config.mjs
│ │ ├── vite.e2e.config.js
│ │ └── vitest.setup.ts
│ ├── cli
│ │ ├── .prettierignore
│ │ ├── commands
│ │ │ ├── apps.ts
│ │ │ ├── auth.ts
│ │ │ ├── flags.ts
│ │ │ ├── init.ts
│ │ │ ├── mcp.ts
│ │ │ ├── new.ts
│ │ │ └── rules.ts
│ │ ├── eslint.config.js
│ │ ├── index.ts
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── schema.json
│ │ ├── services
│ │ │ ├── bootstrap.ts
│ │ │ ├── flags.ts
│ │ │ ├── mcp.ts
│ │ │ └── rules.ts
│ │ ├── stores
│ │ │ ├── auth.ts
│ │ │ └── config.ts
│ │ ├── test
│ │ │ └── json.test.ts
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ ├── utils
│ │ │ ├── auth.ts
│ │ │ ├── commander.ts
│ │ │ ├── constants.ts
│ │ │ ├── errors.ts
│ │ │ ├── file.ts
│ │ │ ├── gen.ts
│ │ │ ├── json.ts
│ │ │ ├── options.ts
│ │ │ ├── schemas.ts
│ │ │ ├── types.ts
│ │ │ ├── urls.ts
│ │ │ └── version.ts
│ │ └── vite.config.js
│ ├── eslint-config
│ │ ├── base.js
│ │ └── package.json
│ ├── flag-evaluation
│ │ ├── .prettierignore
│ │ ├── eslint.config.js
│ │ ├── jest.config.js
│ │ ├── package.json
│ │ ├── src
│ │ │ └── index.ts
│ │ ├── test
│ │ │ └── index.test.ts
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ └── tsconfig.json
│ ├── node-sdk
│ │ ├── .prettierignore
│ │ ├── docs
│ │ │ ├── type-check-failed.png
│ │ │ └── type-check-payload-failed.png
│ │ ├── eslint.config.js
│ │ ├── examples
│ │ │ ├── cloudflare-worker
│ │ │ │ ├── .gitignore
│ │ │ │ ├── .prettierignore
│ │ │ │ ├── .vscode
│ │ │ │ │ └── settings.json
│ │ │ │ ├── package.json
│ │ │ │ ├── README.md
│ │ │ │ ├── src
│ │ │ │ │ └── index.ts
│ │ │ │ ├── tsconfig.json
│ │ │ │ ├── vitest.config.mts
│ │ │ │ ├── worker-configuration.d.ts
│ │ │ │ ├── wrangler.jsonc
│ │ │ │ └── yarn.lock
│ │ │ └── express
│ │ │ ├── app.test.ts
│ │ │ ├── app.ts
│ │ │ ├── bucket.ts
│ │ │ ├── bucketConfig.json
│ │ │ ├── package.json
│ │ │ ├── README.md
│ │ │ ├── serve.ts
│ │ │ ├── tsconfig.json
│ │ │ └── yarn.lock
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── batch-buffer.ts
│ │ │ ├── client.ts
│ │ │ ├── config.ts
│ │ │ ├── edgeClient.ts
│ │ │ ├── fetch-http-client.ts
│ │ │ ├── flusher.ts
│ │ │ ├── index.ts
│ │ │ ├── inRequestCache.ts
│ │ │ ├── periodicallyUpdatingCache.ts
│ │ │ ├── rate-limiter.ts
│ │ │ ├── types.ts
│ │ │ └── utils.ts
│ │ ├── test
│ │ │ ├── batch-buffer.test.ts
│ │ │ ├── client.test.ts
│ │ │ ├── config.test.ts
│ │ │ ├── fetch-http-client.test.ts
│ │ │ ├── flusher.test.ts
│ │ │ ├── inRequestCache.test.ts
│ │ │ ├── periodicallyUpdatingCache.test.ts
│ │ │ ├── rate-limiter.test.ts
│ │ │ ├── testConfig.json
│ │ │ └── utils.test.ts
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ ├── typedoc.json
│ │ └── vite.config.js
│ ├── openfeature-browser-provider
│ │ ├── .prettierignore
│ │ ├── eslint.config.js
│ │ ├── example
│ │ │ ├── .eslintrc.json
│ │ │ ├── .gitignore
│ │ │ ├── app
│ │ │ │ ├── featureManagement.ts
│ │ │ │ ├── globals.css
│ │ │ │ ├── layout.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── components
│ │ │ │ ├── Context.tsx
│ │ │ │ ├── HuddleFeature.tsx
│ │ │ │ └── OpenFeatureProvider.tsx
│ │ │ ├── next.config.mjs
│ │ │ ├── package.json
│ │ │ ├── postcss.config.mjs
│ │ │ ├── README.md
│ │ │ ├── tailwind.config.ts
│ │ │ └── tsconfig.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── index.test.ts
│ │ │ └── index.ts
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ └── vite.config.js
│ ├── openfeature-node-provider
│ │ ├── .prettierignore
│ │ ├── eslint.config.js
│ │ ├── example
│ │ │ ├── app.ts
│ │ │ ├── package.json
│ │ │ ├── README.md
│ │ │ ├── reflag.ts
│ │ │ ├── serve.ts
│ │ │ ├── tsconfig.json
│ │ │ └── yarn.lock
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── index.test.ts
│ │ │ └── index.ts
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ └── vite.config.js
│ ├── react-sdk
│ │ ├── .prettierignore
│ │ ├── dev
│ │ │ ├── .env
│ │ │ ├── nextjs-bootstrap-demo
│ │ │ │ ├── .eslintrc.json
│ │ │ │ ├── .gitignore
│ │ │ │ ├── app
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── favicon.ico
│ │ │ │ │ ├── globals.css
│ │ │ │ │ ├── layout.tsx
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── components
│ │ │ │ │ └── Flags.tsx
│ │ │ │ ├── next.config.mjs
│ │ │ │ ├── package.json
│ │ │ │ ├── postcss.config.mjs
│ │ │ │ ├── public
│ │ │ │ │ ├── next.svg
│ │ │ │ │ └── vercel.svg
│ │ │ │ ├── README.md
│ │ │ │ ├── tailwind.config.ts
│ │ │ │ └── tsconfig.json
│ │ │ ├── nextjs-flag-demo
│ │ │ │ ├── .eslintrc.json
│ │ │ │ ├── .gitignore
│ │ │ │ ├── app
│ │ │ │ │ ├── favicon.ico
│ │ │ │ │ ├── globals.css
│ │ │ │ │ ├── layout.tsx
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── components
│ │ │ │ │ ├── Flags.tsx
│ │ │ │ │ └── Providers.tsx
│ │ │ │ ├── next.config.mjs
│ │ │ │ ├── package.json
│ │ │ │ ├── postcss.config.mjs
│ │ │ │ ├── public
│ │ │ │ │ ├── next.svg
│ │ │ │ │ └── vercel.svg
│ │ │ │ ├── README.md
│ │ │ │ ├── tailwind.config.ts
│ │ │ │ └── tsconfig.json
│ │ │ └── plain
│ │ │ ├── app.tsx
│ │ │ ├── index.html
│ │ │ ├── index.tsx
│ │ │ ├── tsconfig.json
│ │ │ └── vite-env.d.ts
│ │ ├── eslint.config.js
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ └── index.tsx
│ │ ├── test
│ │ │ └── usage.test.tsx
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ ├── typedoc.json
│ │ └── vite.config.mjs
│ ├── tsconfig
│ │ ├── library.json
│ │ └── package.json
│ └── vue-sdk
│ ├── .prettierignore
│ ├── dev
│ │ └── plain
│ │ ├── App.vue
│ │ ├── components
│ │ │ ├── Events.vue
│ │ │ ├── FlagsList.vue
│ │ │ ├── MissingKeyMessage.vue
│ │ │ ├── RequestFeedback.vue
│ │ │ ├── Section.vue
│ │ │ ├── StartHuddlesButton.vue
│ │ │ └── Track.vue
│ │ ├── env.d.ts
│ │ ├── index.html
│ │ └── index.ts
│ ├── eslint.config.js
│ ├── package.json
│ ├── README.md
│ ├── src
│ │ ├── hooks.ts
│ │ ├── index.ts
│ │ ├── ReflagBootstrappedProvider.vue
│ │ ├── ReflagClientProvider.vue
│ │ ├── ReflagProvider.vue
│ │ ├── types.ts
│ │ ├── version.ts
│ │ └── vue.d.ts
│ ├── test
│ │ └── usage.test.ts
│ ├── tsconfig.build.json
│ ├── tsconfig.eslint.json
│ ├── tsconfig.json
│ ├── typedoc.json
│ └── vite.config.mjs
├── README.md
├── typedoc.json
├── vitest.workspace.js
└── yarn.lock
```
# Files
--------------------------------------------------------------------------------
/packages/node-sdk/src/fetch-http-client.ts:
--------------------------------------------------------------------------------
```typescript
import { API_TIMEOUT_MS } from "./config";
import { HttpClient } from "./types";
import { ok } from "./utils";
/**
* The default HTTP client implementation.
*
* @remarks
* This implementation uses the `fetch` API to send HTTP requests.
**/
const fetchClient: HttpClient = {
post: async <TBody, TResponse>(
url: string,
headers: Record<string, string>,
body: TBody,
timeoutMs: number = API_TIMEOUT_MS,
) => {
ok(typeof url === "string" && url.length > 0, "URL must be a string");
ok(typeof headers === "object", "Headers must be an object");
const response = await fetch(url, {
method: "post",
headers,
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs),
});
const json = await response.json();
return {
ok: response.ok,
status: response.status,
body: json as TResponse,
};
},
get: async <TResponse>(
url: string,
headers: Record<string, string>,
timeoutMs: number = API_TIMEOUT_MS,
) => {
ok(typeof url === "string" && url.length > 0, "URL must be a string");
ok(typeof headers === "object", "Headers must be an object");
const response = await fetch(url, {
method: "get",
headers,
signal: AbortSignal.timeout(timeoutMs),
// We must use no-cache to avoid services such as Next.js from caching the response indefinitely.
// We also can't use no-store because of Next.js error withRetry https://github.com/vercel/next.js/discussions/54036.
// We also have local caching in the SDKs, so we don't need to cache the response.
cache: "no-cache",
});
const json = await response.json();
return {
ok: response.ok,
status: response.status,
body: json as TResponse,
};
},
};
/**
* Implements exponential backoff retry logic for async functions.
*
* @param fn - The async function to retry.
* @param maxRetries - Maximum number of retry attempts.
* @param baseDelay - Base delay in milliseconds before retrying.
* @param maxDelay - Maximum delay in milliseconds.
* @returns The result of the function call or throws an error if all retries fail.
*/
export async function withRetry<T>(
fn: () => Promise<T>,
onFailedTry: (error: unknown) => void,
maxRetries: number,
baseDelay: number,
maxDelay: number,
): Promise<T> {
let lastError: unknown;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (attempt === maxRetries) {
break;
}
onFailedTry(error);
// Calculate exponential backoff with jitter
const delay = Math.min(
maxDelay,
baseDelay * Math.pow(2, attempt) * (0.8 + Math.random() * 0.4),
);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw lastError;
}
export default fetchClient;
```
--------------------------------------------------------------------------------
/packages/vue-sdk/test/usage.test.ts:
--------------------------------------------------------------------------------
```typescript
import { mount } from "@vue/test-utils";
import { beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import { defineComponent, h, nextTick } from "vue";
import { ReflagClient } from "@reflag/browser-sdk";
import {
ReflagBootstrappedProvider,
ReflagProvider,
useClient,
useFlag,
} from "../src";
// Mock ReflagClient prototype methods like the React SDK tests
beforeAll(() => {
vi.spyOn(ReflagClient.prototype, "initialize").mockResolvedValue(undefined);
vi.spyOn(ReflagClient.prototype, "stop").mockResolvedValue(undefined);
vi.spyOn(ReflagClient.prototype, "getFlag").mockReturnValue({
isEnabled: true,
config: { key: "default", payload: { message: "Hello" } },
track: vi.fn().mockResolvedValue(undefined),
requestFeedback: vi.fn(),
setIsEnabledOverride: vi.fn(),
isEnabledOverride: null,
});
vi.spyOn(ReflagClient.prototype, "getFlags").mockReturnValue({});
vi.spyOn(ReflagClient.prototype, "on").mockReturnValue(() => {
// cleanup function
});
vi.spyOn(ReflagClient.prototype, "off").mockImplementation(() => {
// off implementation
});
});
beforeEach(() => {
vi.clearAllMocks();
});
function getProvider() {
return {
props: {
publishableKey: "key",
},
};
}
describe("ReflagProvider", () => {
test("provides the client", async () => {
const Child = defineComponent({
setup() {
const client = useClient();
return { client };
},
template: "<div></div>",
});
const wrapper = mount(ReflagProvider, {
...getProvider(),
slots: { default: () => h(Child) },
});
await nextTick();
expect(wrapper.findComponent(Child).vm.client).toBeDefined();
});
test("throws without provider", () => {
const Comp = defineComponent({
setup() {
return () => {
useClient();
};
},
});
expect(() => mount(Comp)).toThrow();
});
});
describe("ReflagBootstrappedProvider", () => {
test("provides the client with bootstrapped flags", async () => {
const bootstrappedFlags = {
context: {
user: { id: "test-user" },
company: { id: "test-company" },
},
flags: {
"test-flag": {
key: "test-flag",
isEnabled: true,
config: { key: "default", payload: { message: "Hello" } },
},
},
};
const Child = defineComponent({
setup() {
const client = useClient();
const flag = useFlag("test-flag");
return { client, flag };
},
template: "<div></div>",
});
const wrapper = mount(ReflagBootstrappedProvider, {
props: {
publishableKey: "key",
flags: bootstrappedFlags,
},
slots: { default: () => h(Child) },
});
await nextTick();
expect(wrapper.findComponent(Child).vm.client).toBeDefined();
expect(wrapper.findComponent(Child).vm.flag.isEnabled.value).toBe(true);
});
});
```
--------------------------------------------------------------------------------
/packages/cli/commands/init.ts:
--------------------------------------------------------------------------------
```typescript
import { input, select } from "@inquirer/prompts";
import chalk from "chalk";
import { Command } from "commander";
import { relative } from "node:path";
import ora, { Ora } from "ora";
import { App, listApps } from "../services/bootstrap.js";
import { configStore, typeFormats } from "../stores/config.js";
import { DEFAULT_TYPES_OUTPUT } from "../utils/constants.js";
import { handleError } from "../utils/errors.js";
import { overwriteOption } from "../utils/options.js";
type InitArgs = {
overwrite?: boolean;
};
export const initAction = async (args: InitArgs = {}) => {
let spinner: Ora | undefined;
let apps: App[] = [];
try {
// Check if config already exists
const configPath = configStore.getConfigPath();
if (configPath && !args.overwrite) {
throw new Error(
"Reflag is already initialized. Use --overwrite to overwrite.",
);
}
console.log("\nWelcome to ◪ Reflag!\n");
const baseUrl = configStore.getConfig("baseUrl");
// Load apps
spinner = ora(`Loading apps from ${chalk.cyan(baseUrl)}...`).start();
apps = listApps();
spinner.succeed(`Loaded apps from ${chalk.cyan(baseUrl)}.`);
} catch (error) {
spinner?.fail("Loading apps failed.");
handleError(error, "Initialization");
}
try {
let appId: string | undefined;
const nonDemoApp = apps.find((app) => !app.demo);
if (apps.length === 0) {
throw new Error("You don't have any apps yet. Please create one.");
} else {
const longestName = Math.max(...apps.map((app) => app.name.length));
appId = await select({
message: "Select an app",
default: nonDemoApp?.id,
choices: apps.map((app) => ({
name: `${app.name.padEnd(longestName, " ")}${app.demo ? " [Demo]" : ""}`,
value: app.id,
})),
});
}
// Get types output path
const typesOutput = await input({
message: "Where should we generate the types?",
default: DEFAULT_TYPES_OUTPUT,
});
// Get types output format
const typesFormat = await select({
message: "What is the output format?",
choices: typeFormats.map((format) => ({
name: format,
value: format,
})),
default: "react",
});
// Update config
configStore.setConfig({
appId,
typesOutput: [{ path: typesOutput, format: typesFormat }],
});
// Create config file
spinner = ora("Creating configuration...").start();
await configStore.saveConfigFile(args.overwrite);
spinner.succeed(
`Configuration created at ${chalk.cyan(relative(process.cwd(), configStore.getConfigPath()!))}.`,
);
} catch (error) {
spinner?.fail("Configuration creation failed.");
handleError(error, "Initialization");
}
};
export function registerInitCommand(cli: Command) {
cli
.command("init")
.description("Initialize a new Reflag configuration.")
.addOption(overwriteOption)
.action(initAction);
}
```
--------------------------------------------------------------------------------
/packages/browser-sdk/src/feedback/ui/FeedbackForm.css:
--------------------------------------------------------------------------------
```css
.container {
overflow-y: hidden;
transition: max-height 400ms cubic-bezier(0.65, 0, 0.35, 1);
}
.form {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
overflow-y: hidden;
max-height: 400px;
transition: opacity 400ms cubic-bezier(0.65, 0, 0.35, 1);
}
.form-control {
display: flex;
flex-direction: column;
width: 100%;
gap: 8px;
border: none;
padding: 0;
margin: 0;
font-size: 12px;
color: var(--reflag-feedback-dialog-secondary-color, #787c91);
}
.form-expanded-content {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
transition: opacity 400ms cubic-bezier(0.65, 0, 0.35, 1);
opacity: 0;
position: absolute;
top: 0;
left: 0;
}
.title {
color: var(--reflag-feedback-dialog-color, #1e1f24);
font-size: 15px;
font-weight: 400;
line-height: 115%;
text-wrap: balance;
max-width: calc(100% - 20px);
margin-bottom: 6px;
line-height: 1.3;
}
.dimmed {
opacity: 0.5;
}
.textarea {
background-color: transparent;
border: 1px solid;
border-color: var(--reflag-feedback-dialog-input-border-color, #d8d9df);
padding: 0.5rem 0.75rem;
border-radius: var(--reflag-feedback-dialog-border-radius, 6px);
transition: border-color 0.2s ease-in-out;
font-family: var(
--reflag-feedback-dialog-font-family,
InterVariable,
Inter,
system-ui,
Open Sans,
sans-serif
);
line-height: 1.3;
resize: none;
color: var(--reflag-feedback-dialog-color, #1e1f24);
font-size: 13px;
&::placeholder {
color: var(--reflag-feedback-dialog-color, #1e1f24);
opacity: 0.36;
}
&:focus {
outline: none;
border-color: var(
--reflag-feedback-dialog-input-focus-border-color,
#787c91
);
}
}
.score-status-container {
position: relative;
padding-bottom: 6px;
height: 14px;
> .score-status {
display: flex;
align-items: center;
position: absolute;
top: 0;
left: 0;
opacity: 0;
transition: opacity 200ms ease-in-out;
}
}
.error {
margin: 0;
color: var(--reflag-feedback-dialog-error-color, #e53e3e);
font-size: 0.8125em;
font-weight: 500;
}
.submitted {
display: flex;
flex-direction: column;
transition: opacity 400ms cubic-bezier(0.65, 0, 0.35, 1);
position: absolute;
top: 0;
left: 0;
opacity: 0;
pointer-events: none;
width: calc(100% - 56px);
padding: 0px 28px;
.submitted-check {
background: var(--reflag-feedback-dialog-submitted-check-color, #fff);
color: var(
--reflag-feedback-dialog-submitted-check-background-color,
#38a169
);
height: 24px;
width: 24px;
display: block;
border-radius: 50%;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
margin: 16px auto 8px;
}
.text {
margin: auto auto 16px;
text-align: center;
color: var(--reflag-feedback-dialog-color, #1e1f24);
font-size: var(--reflag-feedback-dialog-font-size, 1rem);
font-weight: 400;
line-height: 130%;
flex-grow: 1;
max-width: 160px;
}
> .plug {
flex-grow: 0;
}
}
```
--------------------------------------------------------------------------------
/packages/browser-sdk/src/feedback/ui/FeedbackDialog.tsx:
--------------------------------------------------------------------------------
```typescript
import { Fragment, FunctionComponent, h } from "preact";
import { useCallback, useState } from "preact/hooks";
import { feedbackContainerId } from "../../ui/constants";
import { Dialog, useDialog } from "../../ui/Dialog";
import { Close } from "../../ui/icons/Close";
import { DEFAULT_TRANSLATIONS } from "./config/defaultTranslations";
import { useTimer } from "./hooks/useTimer";
import { FeedbackForm } from "./FeedbackForm";
import styles from "./index.css?inline";
import { RadialProgress } from "./RadialProgress";
import {
FeedbackScoreSubmission,
FeedbackSubmission,
OpenFeedbackFormOptions,
WithRequired,
} from "./types";
export type FeedbackDialogProps = WithRequired<
OpenFeedbackFormOptions,
"onSubmit" | "position"
>;
const INACTIVE_DURATION_MS = 20 * 1000;
const SUCCESS_DURATION_MS = 3 * 1000;
export const FeedbackDialog: FunctionComponent<FeedbackDialogProps> = ({
key,
title = DEFAULT_TRANSLATIONS.DefaultQuestionLabel,
position,
translations = DEFAULT_TRANSLATIONS,
openWithCommentVisible = false,
onClose,
onDismiss,
onSubmit,
onScoreSubmit,
}) => {
const [feedbackId, setFeedbackId] = useState<string | undefined>(undefined);
const [scoreState, setScoreState] = useState<
"idle" | "submitting" | "submitted"
>("idle");
const { isOpen, close } = useDialog({ onClose, initialValue: true });
const autoClose = useTimer({
enabled: position.type === "DIALOG",
initialDuration: INACTIVE_DURATION_MS,
onEnd: close,
});
const submit = useCallback(
async (data: Omit<FeedbackSubmission, "feedbackId">) => {
await onSubmit({ ...data, feedbackId });
autoClose.startWithDuration(SUCCESS_DURATION_MS);
},
[autoClose, feedbackId, onSubmit],
);
const submitScore = useCallback(
async (data: Omit<FeedbackScoreSubmission, "feedbackId">) => {
if (onScoreSubmit !== undefined) {
setScoreState("submitting");
const res = await onScoreSubmit({ ...data, feedbackId });
setFeedbackId(res.feedbackId);
setScoreState("submitted");
}
},
[feedbackId, onScoreSubmit],
);
const dismiss = useCallback(() => {
autoClose.stop();
close();
onDismiss?.();
}, [autoClose, close, onDismiss]);
return (
<>
<style dangerouslySetInnerHTML={{ __html: styles }} />
<Dialog
key={key}
close={close}
containerId={feedbackContainerId}
isOpen={isOpen}
position={position}
onDismiss={onDismiss}
>
<>
<FeedbackForm
key={key}
openWithCommentVisible={openWithCommentVisible}
question={title}
scoreState={scoreState}
t={{ ...DEFAULT_TRANSLATIONS, ...translations }}
onInteraction={autoClose.stop}
onScoreSubmit={submitScore}
onSubmit={submit}
/>
<button class="close" onClick={dismiss}>
{!autoClose.stopped && autoClose.elapsedFraction > 0 && (
<RadialProgress
diameter={28}
progress={1.0 - autoClose.elapsedFraction}
/>
)}
<Close />
</button>
</>
</Dialog>
</>
);
};
```
--------------------------------------------------------------------------------
/packages/browser-sdk/test/e2e/acceptance.browser.spec.ts:
--------------------------------------------------------------------------------
```typescript
import { randomUUID } from "crypto";
import { expect, test } from "@playwright/test";
import { API_BASE_URL } from "../../src/config";
const KEY = randomUUID();
test("Acceptance", async ({ page }) => {
await page.goto("http://localhost:8001/test/e2e/empty.html");
const successfulRequests: string[] = [];
// Mock API calls with assertions
await page.route(`${API_BASE_URL}/features/evaluated*`, async (route) => {
successfulRequests.push("FLAGS");
await route.fulfill({
status: 200,
body: JSON.stringify({
success: true,
features: {},
}),
});
});
await page.route(`${API_BASE_URL}/user`, async (route) => {
expect(route.request().method()).toEqual("POST");
expect(route.request().postDataJSON()).toMatchObject({
userId: "foo",
attributes: {
name: "john doe",
},
});
successfulRequests.push("USER");
await route.fulfill({
status: 200,
body: JSON.stringify({ success: true }),
});
});
await page.route(`${API_BASE_URL}/company`, async (route) => {
expect(route.request().method()).toEqual("POST");
expect(route.request().postDataJSON()).toMatchObject({
userId: "foo",
companyId: "bar",
attributes: {
name: "bar corp",
},
});
successfulRequests.push("COMPANY");
await route.fulfill({
status: 200,
body: JSON.stringify({ success: true }),
});
});
await page.route(`${API_BASE_URL}/event`, async (route) => {
expect(route.request().method()).toEqual("POST");
expect(route.request().postDataJSON()).toMatchObject({
userId: "foo",
companyId: "bar",
event: "baz",
attributes: {
baz: true,
},
});
successfulRequests.push("EVENT");
await route.fulfill({
status: 200,
body: JSON.stringify({ success: true }),
});
});
await page.route(`${API_BASE_URL}/feedback`, async (route) => {
expect(route.request().method()).toEqual("POST");
expect(route.request().postDataJSON()).toMatchObject({
userId: "foo",
companyId: "bar",
featureId: "featureId1",
score: 5,
comment: "test!",
question: "actual question",
promptedQuestion: "prompted question",
});
successfulRequests.push("FEEDBACK");
await route.fulfill({
status: 200,
body: JSON.stringify({ success: true }),
});
});
// Golden path requests
await page.evaluate(`
;(async () => {
const { ReflagClient } = await import("/dist/reflag-browser-sdk.mjs");
const reflagClient = new ReflagClient({
publishableKey: "${KEY}",
user: {
id: "foo",
name: "john doe",
},
company: {
id: "bar",
name: "bar corp",
}
});
await reflagClient.initialize();
await reflagClient.track("baz", { baz: true }, "foo", "bar");
await reflagClient.feedback({
featureId: "featureId1",
score: 5,
comment: "test!",
question: "actual question",
promptedQuestion: "prompted question",
});
})()
`);
// Assert all API requests were made
expect(successfulRequests).toEqual([
"FLAGS",
"USER",
"COMPANY",
"EVENT",
"FEEDBACK",
]);
});
```
--------------------------------------------------------------------------------
/packages/vue-sdk/dev/plain/components/FlagsList.vue:
--------------------------------------------------------------------------------
```vue
<template>
<Section title="Flags List">
<div v-if="!client">
<p>Client not available</p>
</div>
<div v-else>
<p>This list shows all available flags and their current state:</p>
<ul
v-if="flagEntries.length > 0"
style="list-style-type: none; padding: 0"
>
<li
v-for="[flagKey, flag] in flagEntries"
:key="flagKey"
style="
margin-bottom: 10px;
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
"
>
<div style="display: flex; align-items: center; gap: 10px">
<strong>{{ flagKey }}</strong>
<span
:style="{
color:
(flag.isEnabledOverride ?? flag.isEnabled) ? 'green' : 'red',
}"
>
{{
(flag.isEnabledOverride ?? flag.isEnabled)
? "Enabled"
: "Disabled"
}}
</span>
<!-- Reset button if override is active -->
<button
v-if="flag.isEnabledOverride !== null"
style="margin-left: 10px; padding: 2px 8px; font-size: 12px"
@click="() => resetOverride(flagKey)"
>
Reset
</button>
<!-- Toggle checkbox -->
<input
type="checkbox"
:checked="flag.isEnabledOverride ?? flag.isEnabled"
style="margin-left: auto"
@change="
(e) => {
const isChecked = (e.target as HTMLInputElement).checked;
const isEnabledOverride = flag.isEnabledOverride !== null;
toggleFlag(flagKey, !isEnabledOverride ? isChecked : null);
}
"
/>
</div>
<!-- Show config if available -->
<div
v-if="flag.config && flag.config.key"
style="margin-top: 5px; font-size: 12px; color: #666"
>
<strong>Config:</strong>
<pre
style="
margin: 2px 0;
padding: 4px;
background: #f5f5f5;
border-radius: 2px;
overflow: auto;
"
>{{ JSON.stringify(flag.config.payload, null, 2) }}</pre
>
</div>
</li>
</ul>
<p v-else style="color: #666; font-style: italic">No flags available</p>
</div>
</Section>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { useClient, useOnEvent } from "../../../src";
import Section from "./Section.vue";
const client = useClient();
const flagsData = ref(client.getFlags());
// Update flags data when flags are updated
function updateFlags() {
flagsData.value = client.getFlags();
}
// Update flags data when flags are updated
useOnEvent("flagsUpdated", updateFlags);
const flagEntries = computed(() => {
return Object.entries(flagsData.value);
});
function resetOverride(flagKey: string) {
client.getFlag(flagKey).setIsEnabledOverride(null);
updateFlags();
}
function toggleFlag(flagKey: string, checked: boolean | null) {
// Use simplified logic similar to React implementation
client.getFlag(flagKey).setIsEnabledOverride(checked);
updateFlags();
}
</script>
```
--------------------------------------------------------------------------------
/packages/browser-sdk/src/ui/packages/floating-ui-preact-dom/types.ts:
--------------------------------------------------------------------------------
```typescript
import type {
ComputePositionConfig,
ComputePositionReturn,
VirtualElement,
} from "@floating-ui/dom";
import { h, RefObject } from "preact";
export { arrow, Options as ArrowOptions } from "./arrow";
export { useFloating } from "./useFloating";
export type {
AlignedPlacement,
Alignment,
AutoPlacementOptions,
AutoUpdateOptions,
Axis,
Boundary,
ClientRectObject,
ComputePositionConfig,
ComputePositionReturn,
Coords,
DetectOverflowOptions,
Dimensions,
ElementContext,
ElementRects,
Elements,
FlipOptions,
FloatingElement,
HideOptions,
InlineOptions,
Length,
Middleware,
MiddlewareArguments,
MiddlewareData,
MiddlewareReturn,
MiddlewareState,
NodeScroll,
OffsetOptions,
Padding,
Placement,
Platform,
Rect,
ReferenceElement,
RootBoundary,
ShiftOptions,
Side,
SideObject,
SizeOptions,
Strategy,
VirtualElement,
} from "@floating-ui/dom";
export {
autoPlacement,
autoUpdate,
computePosition,
detectOverflow,
flip,
getOverflowAncestors,
hide,
inline,
limitShift,
offset,
platform,
shift,
size,
} from "@floating-ui/dom";
type Prettify<T> = {
[K in keyof T]: T[K];
} & {};
export type UseFloatingData = Prettify<
ComputePositionReturn & { isPositioned: boolean }
>;
export type ReferenceType = Element | VirtualElement;
export type UseFloatingReturn<RT extends ReferenceType = ReferenceType> =
Prettify<
UseFloatingData & {
/**
* Update the position of the floating element, re-rendering the component
* if required.
*/
update: () => void;
/**
* Pre-configured positioning styles to apply to the floating element.
*/
floatingStyles: h.JSX.CSSProperties;
/**
* Object containing the reference and floating refs and reactive setters.
*/
refs: {
/**
* A React ref to the reference element.
*/
reference: RefObject<RT | null>;
/**
* A React ref to the floating element.
*/
floating: RefObject<HTMLElement | null>;
/**
* A callback to set the reference element (reactive).
*/
setReference: (node: RT | null) => void;
/**
* A callback to set the floating element (reactive).
*/
setFloating: (node: HTMLElement | null) => void;
};
elements: {
reference: RT | null;
floating: HTMLElement | null;
};
}
>;
export type UseFloatingOptions<RT extends ReferenceType = ReferenceType> =
Prettify<
Partial<ComputePositionConfig> & {
/**
* A callback invoked when both the reference and floating elements are
* mounted, and cleaned up when either is unmounted. This is useful for
* setting up event listeners (e.g. pass `autoUpdate`).
*/
whileElementsMounted?: (
reference: RT,
floating: HTMLElement,
update: () => void,
) => () => void;
elements?: {
reference?: RT | null;
floating?: HTMLElement | null;
};
/**
* The `open` state of the floating element to synchronize with the
* `isPositioned` value.
*/
open?: boolean;
/**
* Whether to use `transform` for positioning instead of `top` and `left`
* (layout) in the `floatingStyles` object.
*/
transform?: boolean;
}
>;
```
--------------------------------------------------------------------------------
/packages/openfeature-node-provider/example/app.ts:
--------------------------------------------------------------------------------
```typescript
import express from "express";
import "./reflag";
import { EvaluationContext, OpenFeature } from "@openfeature/server-sdk";
import { CreateTodosConfig } from "./reflag";
// In the following, we assume that targetingKey is a unique identifier for the user.
type Context = EvaluationContext & {
targetingKey: string;
companyId: string;
};
// Augment the Express types to include the some context property on the `res.locals` object.
declare global {
namespace Express {
interface Locals {
context: Context;
}
}
}
const app = express();
app.use(express.json());
app.use((req, res, next) => {
const ofContext = {
targetingKey: "user42",
companyId: "company99",
};
res.locals.context = ofContext;
next();
});
const todos = ["Buy milk", "Walk the dog"];
app.get("/", (_req, res) => {
const ofClient = OpenFeature.getClient();
ofClient.track("front-page-viewed", res.locals.context);
res.json({ message: "Ready to manage some TODOs!" });
});
app.get("/todos", async (req, res) => {
// Return todos if the feature is enabled for the user
// We use the `getFlag` method to check if the user has the "show-todo" feature enabled.
// Note that "show-todo" is a flag that we defined in the `Flags` interface in the `reflag.ts` file.
// and that the indexing for flag name below is type-checked at compile time.
const ofClient = OpenFeature.getClient();
const isEnabled = await ofClient.getBooleanValue(
"show-todos",
false,
res.locals.context,
);
if (isEnabled) {
ofClient.track("show-todo", res.locals.context);
return res.json({ todos });
}
return res
.status(403)
.json({ error: "You do not have access to this feature yet!" });
});
app.post("/todos", async (req, res) => {
const { todo } = req.body;
if (typeof todo !== "string") {
return res.status(400).json({ error: "Invalid todo" });
}
const ofClient = OpenFeature.getClient();
const isEnabled = await ofClient.getBooleanValue(
"create-todo",
false,
res.locals.context,
);
// Check if the user has the "create-todos" feature enabled.
if (isEnabled) {
// Get the configuration for the "create-todos" feature.
// We expect the configuration to be a JSON object with a `maxLength` property.
const config = await ofClient.getObjectValue<CreateTodosConfig>(
"create-todos",
{ maxLength: 100 },
res.locals.context,
);
// Check if the todo is too long.
if (todo.length > config.maxLength) {
return res.status(400).json({ error: "Todo is too long" });
}
// Track the feature usage
ofClient.track("create-todos", res.locals.context);
todos.push(todo);
return res.status(201).json({ todo });
}
res
.status(403)
.json({ error: "You do not have access to this feature yet!" });
});
app.delete("/todos/:idx", async (req, res) => {
const idx = parseInt(req.params.idx);
if (isNaN(idx) || idx < 0 || idx >= todos.length) {
return res.status(400).json({ error: "Invalid index" });
}
const ofClient = OpenFeature.getClient();
const isEnabled = await ofClient.getBooleanValue(
"delete-todos",
false,
res.locals.context,
);
if (isEnabled) {
todos.splice(idx, 1);
ofClient.track("delete-todos", res.locals.context);
return res.json({});
}
res
.status(403)
.json({ error: "You do not have access to this feature yet!" });
});
export default app;
```
--------------------------------------------------------------------------------
/packages/node-sdk/test/rate-limiter.test.ts:
--------------------------------------------------------------------------------
```typescript
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { newRateLimiter } from "../src/rate-limiter";
describe("rateLimiter", () => {
beforeAll(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterAll(() => {
vi.useRealTimers();
});
const windowSizeMs = 1000;
describe("isAllowed", () => {
it("should rate limit", () => {
const limiter = newRateLimiter(windowSizeMs);
expect(limiter.isAllowed("key")).toBe(true);
expect(limiter.isAllowed("key")).toBe(false);
});
it("should reset the limit in given time", () => {
const limiter = newRateLimiter(windowSizeMs);
limiter.isAllowed("key");
vi.advanceTimersByTime(windowSizeMs);
expect(limiter.isAllowed("key")).toBe(false);
vi.advanceTimersByTime(1);
expect(limiter.isAllowed("key")).toBe(true);
});
it("should measure events separately by key", () => {
const limiter = newRateLimiter(windowSizeMs);
expect(limiter.isAllowed("key1")).toBe(true);
vi.advanceTimersByTime(windowSizeMs);
expect(limiter.isAllowed("key2")).toBe(true);
expect(limiter.isAllowed("key1")).toBe(false);
vi.advanceTimersByTime(1);
expect(limiter.isAllowed("key1")).toBe(true);
vi.advanceTimersByTime(windowSizeMs);
expect(limiter.isAllowed("key2")).toBe(true);
});
});
describe("clearStale", () => {
it("should clear expired events, but keep non-expired", () => {
const rateLimiter = newRateLimiter(windowSizeMs);
rateLimiter.isAllowed("key1");
expect(rateLimiter.cacheSize()).toBe(1);
vi.advanceTimersByTime(windowSizeMs / 2); // 500ms
rateLimiter.isAllowed("key2");
expect(rateLimiter.cacheSize()).toBe(2);
vi.advanceTimersByTime(windowSizeMs / 2 + 1); // 1001ms total
// at this point, key1 is stale, but key2 is not
rateLimiter.clearStale();
expect(rateLimiter.cacheSize()).toBe(1);
// key2 should still be in the cache, and thus rate-limited
expect(rateLimiter.isAllowed("key2")).toBe(false);
// key1 should have been removed, so it's allowed again
expect(rateLimiter.isAllowed("key1")).toBe(true);
expect(rateLimiter.cacheSize()).toBe(2);
});
});
it("should periodically clean up expired keys", () => {
const mathRandomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5);
const rateLimiter = newRateLimiter(windowSizeMs);
// Add key1, cache size is 1.
rateLimiter.isAllowed("key1");
expect(rateLimiter.cacheSize()).toBe(1);
// Advance time so key1 becomes stale.
vi.advanceTimersByTime(windowSizeMs + 1);
// Trigger another call for a different key.
// This should not clear anything, cache size becomes 2.
rateLimiter.isAllowed("key2");
expect(rateLimiter.cacheSize()).toBe(2);
// Mock random to trigger clearStale on the next call.
mathRandomSpy.mockReturnValue(0.005);
// This call for a new key ("key3") should trigger a cleanup.
// "key1" is stale and will be cleared. "key2" remains. "key3" is added.
// Cache size should go from 2 -> 1 (clear) -> 2 (add).
rateLimiter.isAllowed("key3");
expect(rateLimiter.cacheSize()).toBe(2);
// To confirm "key1" was cleared, we should be able to add it again.
expect(rateLimiter.isAllowed("key1")).toBe(true);
expect(rateLimiter.cacheSize()).toBe(3);
mathRandomSpy.mockRestore();
});
});
```
--------------------------------------------------------------------------------
/packages/cli/commands/rules.ts:
--------------------------------------------------------------------------------
```typescript
import { confirm } from "@inquirer/prompts";
import chalk from "chalk";
import { Command } from "commander";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join, relative } from "node:path";
import ora from "ora";
import { getCopilotInstructions, getCursorRules } from "../services/rules.js";
import { configStore } from "../stores/config.js";
import { handleError } from "../utils/errors.js";
import { fileExists } from "../utils/file.js";
import { rulesFormatOption, yesOption } from "../utils/options.js";
type RulesArgs = {
format?: string;
yes?: boolean;
};
const REFLAG_SECTION_START = "<!-- REFLAG_START -->";
const REFLAG_SECTION_END = "<!-- REFLAG_END -->";
async function confirmOverwrite(
filePath: string,
yes: boolean,
append: boolean = false,
): Promise<boolean> {
if (yes) return true;
if (await fileExists(filePath)) {
const projectPath = configStore.getProjectPath();
const relativePath = relative(projectPath, filePath);
return await confirm({
message: `Rules ${chalk.cyan(relativePath)} already exists. ${
append ? "Append rules?" : "Overwrite rules?"
}`,
default: false,
});
}
return true;
}
function wrapInMarkers(content: string): string {
return `${REFLAG_SECTION_START}\n\n${content}\n\n${REFLAG_SECTION_END}`;
}
function replaceOrAppendSection(
existingContent: string,
newContent: string,
): string {
const wrappedContent = wrapInMarkers(newContent);
const sectionRegex = new RegExp(
`${REFLAG_SECTION_START}[\\s\\S]*?${REFLAG_SECTION_END}`,
"g",
);
if (sectionRegex.test(existingContent)) {
return existingContent.replace(sectionRegex, wrappedContent);
}
return `${existingContent}\n\n${wrappedContent}`;
}
export const rulesAction = async ({
format = "cursor",
yes = false,
}: RulesArgs = {}) => {
const projectPath = configStore.getProjectPath();
const appendFormats = ["copilot"];
let destPath: string;
let content: string;
// Determine destination and content based on format
if (format === "cursor") {
destPath = join(projectPath, ".cursor", "rules", "reflag.mdc");
content = getCursorRules();
} else if (format === "copilot") {
destPath = join(projectPath, ".github", "copilot-instructions.md");
content = getCopilotInstructions();
} else {
console.error(`No rules added. Invalid format ${chalk.cyan(format)}.`);
return;
}
// Check for overwrite and write file
if (await confirmOverwrite(destPath, yes, appendFormats.includes(format))) {
const spinner = ora("Adding rules...").start();
try {
await mkdir(dirname(destPath), { recursive: true });
if (appendFormats.includes(format) && (await fileExists(destPath))) {
const existingContent = await readFile(destPath, "utf-8");
content = replaceOrAppendSection(existingContent, content);
}
await writeFile(destPath, content);
spinner.succeed(
`Rules added to ${chalk.cyan(relative(projectPath, destPath))}.
${chalk.grey("These rules should be committed to your project's version control.")}`,
);
} catch (error) {
spinner.fail("Failed to add rules.");
handleError(error, "Rules");
}
} else {
console.log("Skipping adding rules.");
}
};
export function registerRulesCommand(cli: Command) {
cli
.command("rules")
.description("Add Reflag LLM rules to your project.")
.addOption(rulesFormatOption)
.addOption(yesOption)
.action(rulesAction);
}
```
--------------------------------------------------------------------------------
/packages/vue-sdk/src/types.ts:
--------------------------------------------------------------------------------
```typescript
import type { Ref } from "vue";
import type {
CompanyContext,
InitOptions,
RawFlags,
ReflagClient,
ReflagContext,
RequestFeedbackData,
UserContext,
} from "@reflag/browser-sdk";
export type EmptyFlagRemoteConfig = { key: undefined; payload: undefined };
export type FlagType = {
config?: {
payload: any;
};
};
export type FlagRemoteConfig =
| {
key: string;
payload: any;
}
| EmptyFlagRemoteConfig;
export interface Flag<
TConfig extends FlagType["config"] = EmptyFlagRemoteConfig,
> {
key: string;
isEnabled: Ref<boolean>;
isLoading: Ref<boolean>;
config: Ref<({ key: string } & TConfig) | EmptyFlagRemoteConfig>;
track(): Promise<Response | undefined> | undefined;
requestFeedback: (opts: RequestFlagFeedbackOptions) => void;
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface Flags {}
export type TypedFlags = keyof Flags extends never
? Record<string, Flag>
: {
[TypedFlagKey in keyof Flags]: Flags[TypedFlagKey] extends FlagType
? Flag<Flags[TypedFlagKey]["config"]>
: Flag;
};
export type FlagKey = keyof TypedFlags;
export interface ProviderContextType {
client: ReflagClient;
isLoading: Ref<boolean>;
}
export type BootstrappedFlags = {
context: ReflagContext;
flags: RawFlags;
};
export type RequestFlagFeedbackOptions = Omit<
RequestFeedbackData,
"flagKey" | "featureId"
>;
/**
* Base init options for the ReflagProvider and ReflagBootstrappedProvider.
* @internal
*/
export type ReflagInitOptionsBase = Omit<
InitOptions,
"user" | "company" | "other" | "otherContext" | "bootstrappedFlags"
>;
/**
* Base props for the ReflagProvider and ReflagBootstrappedProvider.
* @internal
*/
export type ReflagBaseProps = {
/**
* Set to `true` to show the loading component while the client is initializing.
*/
initialLoading?: boolean;
/**
* Set to `true` to enable debug logging to the console.
*/
debug?: boolean;
};
/**
* Props for the ReflagClientProvider.
*/
export type ReflagClientProviderProps = Omit<ReflagBaseProps, "debug"> & {
/**
* A pre-initialized ReflagClient to use.
*/
client: ReflagClient;
};
/**
* Props for the ReflagProvider.
*/
export type ReflagProps = ReflagInitOptionsBase &
ReflagBaseProps & {
/**
* The context to use for the ReflagClient containing user, company, and other context.
*/
context?: ReflagContext;
/**
* Company related context. If you provide `id` Reflag will enrich the evaluation context with
* company attributes on Reflag servers.
* @deprecated Use `context` instead, this property will be removed in the next major version
*/
company?: CompanyContext;
/**
* User related context. If you provide `id` Reflag will enrich the evaluation context with
* user attributes on Reflag servers.
* @deprecated Use `context` instead, this property will be removed in the next major version
*/
user?: UserContext;
/**
* Context which is not related to a user or a company.
* @deprecated Use `context` instead, this property will be removed in the next major version
*/
otherContext?: Record<string, string | number | undefined>;
};
/**
* Props for the ReflagBootstrappedProvider.
*/
export type ReflagBootstrappedProps = ReflagInitOptionsBase &
ReflagBaseProps & {
/**
* Pre-fetched flags to be used instead of fetching them from the server.
*/
flags: BootstrappedFlags;
};
```
--------------------------------------------------------------------------------
/packages/node-sdk/test/inRequestCache.test.ts:
--------------------------------------------------------------------------------
```typescript
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import cache from "../src/inRequestCache";
import { Logger } from "../src/types";
describe("inRequestCache", () => {
let fn: () => Promise<number>;
let logger: Logger;
beforeAll(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterAll(() => {
vi.useRealTimers();
});
beforeEach(() => {
fn = vi.fn().mockResolvedValue(42);
logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
});
it("should update the cached value when refreshing", async () => {
const cached = cache(1000, logger, fn);
const result = await cached.refresh();
expect(result).toBe(42);
expect(logger.debug).toHaveBeenCalledWith(
expect.stringMatching("inRequestCache: fetched value"),
42,
);
});
it("should not allow multiple refreses at the same time", async () => {
const cached = cache(1000, logger, fn);
void cached.refresh();
void cached.refresh();
void cached.refresh();
await cached.refresh();
expect(fn).toHaveBeenCalledTimes(1);
expect(logger.debug).toHaveBeenNthCalledWith(
1,
expect.stringMatching("inRequestCache: fetched value"),
42,
);
void cached.refresh();
await cached.refresh();
expect(fn).toHaveBeenCalledTimes(2);
expect(logger.debug).toHaveBeenNthCalledWith(
2,
expect.stringMatching("inRequestCache: fetched value"),
42,
);
});
it("should warn if the cached value is stale", async () => {
const cached = cache(1000, logger, fn);
await cached.refresh();
vi.advanceTimersByTime(1100);
const result = cached.get();
expect(result).toBe(42);
expect(logger.debug).toHaveBeenCalledWith(
expect.stringMatching(
"inRequestCache: stale value, triggering background refresh",
),
);
});
it("should handle update failures gracefully", async () => {
const error = new Error("update failed");
fn = vi.fn().mockRejectedValueOnce(error).mockResolvedValueOnce(42);
const cached = cache(1000, logger, fn);
const first = await cached.refresh();
expect(first).toBeUndefined();
expect(logger.error).toHaveBeenCalledWith(
expect.stringMatching("inRequestCache: error refreshing value"),
error,
);
expect(fn).toHaveBeenCalledTimes(1);
await cached.refresh();
expect(fn).toHaveBeenCalledTimes(2);
expect(logger.debug).toHaveBeenCalledWith(
expect.stringMatching("inRequestCache: fetched value"),
42,
);
const second = cached.get();
expect(second).toBe(42);
});
it("should retain the cached value if the new value is undefined", async () => {
fn = vi.fn().mockResolvedValueOnce(42).mockResolvedValueOnce(undefined);
const cached = cache(1000, logger, fn);
await cached.refresh();
const second = cached.get();
expect(second).toBe(42);
// error refreshing
await cached.refresh();
// should still be the old value
const result = cached.get();
expect(result).toBe(42);
});
it("should not update if cached value is still valid", async () => {
const cached = cache(1000, logger, fn);
const first = await cached.refresh();
vi.advanceTimersByTime(500);
const second = cached.get();
expect(first).toBe(second);
expect(logger.debug).toHaveBeenCalledTimes(1); // Only one update call
});
afterEach(() => {
vi.clearAllTimers();
vi.restoreAllMocks();
});
});
```
--------------------------------------------------------------------------------
/packages/browser-sdk/src/flag/flagCache.ts:
--------------------------------------------------------------------------------
```typescript
import { RawFlags } from "./flags";
interface StorageItem {
get(): string | null;
set(value: string): void;
}
interface cacheEntry {
expireAt: number;
staleAt: number;
flags: RawFlags;
}
// Parse and validate an API flags response
export function parseAPIFlagsResponse(flagsInput: any): RawFlags | undefined {
if (!isObject(flagsInput)) {
return;
}
const flags: RawFlags = {};
for (const key in flagsInput) {
const flag = flagsInput[key];
if (
typeof flag.isEnabled !== "boolean" ||
flag.key !== key ||
typeof flag.targetingVersion !== "number" ||
(flag.config && typeof flag.config !== "object") ||
(flag.missingContextFields &&
!Array.isArray(flag.missingContextFields)) ||
(flag.ruleEvaluationResults && !Array.isArray(flag.ruleEvaluationResults))
) {
return;
}
flags[key] = {
isEnabled: flag.isEnabled,
targetingVersion: flag.targetingVersion,
key,
config: flag.config,
missingContextFields: flag.missingContextFields,
ruleEvaluationResults: flag.ruleEvaluationResults,
};
}
return flags;
}
export interface CacheResult {
flags: RawFlags;
stale: boolean;
}
export class FlagCache {
private storage: StorageItem;
private readonly staleTimeMs: number;
private readonly expireTimeMs: number;
constructor({
storage,
staleTimeMs,
expireTimeMs,
}: {
storage: StorageItem;
staleTimeMs: number;
expireTimeMs: number;
}) {
this.storage = storage;
this.staleTimeMs = staleTimeMs;
this.expireTimeMs = expireTimeMs;
}
set(
key: string,
{
flags,
}: {
flags: RawFlags;
},
) {
let cacheData: CacheData = {};
try {
const cachedResponseRaw = this.storage.get();
if (cachedResponseRaw) {
cacheData = validateCacheData(JSON.parse(cachedResponseRaw)) ?? {};
}
} catch {
// ignore errors
}
cacheData[key] = {
expireAt: Date.now() + this.expireTimeMs,
staleAt: Date.now() + this.staleTimeMs,
flags,
} satisfies cacheEntry;
cacheData = Object.fromEntries(
Object.entries(cacheData).filter(([_k, v]) => v.expireAt > Date.now()),
);
this.storage.set(JSON.stringify(cacheData));
return cacheData;
}
get(key: string): CacheResult | undefined {
try {
const cachedResponseRaw = this.storage.get();
if (cachedResponseRaw) {
const cachedResponse = validateCacheData(JSON.parse(cachedResponseRaw));
if (
cachedResponse &&
cachedResponse[key] &&
cachedResponse[key].expireAt > Date.now()
) {
return {
flags: cachedResponse[key].flags,
stale: cachedResponse[key].staleAt < Date.now(),
};
}
}
} catch {
// ignore errors
}
return;
}
}
type CacheData = Record<string, cacheEntry>;
function validateCacheData(cacheDataInput: any) {
if (!isObject(cacheDataInput)) {
return;
}
const cacheData: CacheData = {};
for (const key in cacheDataInput) {
const cacheEntry = cacheDataInput[key];
if (!isObject(cacheEntry)) return;
if (
typeof cacheEntry.expireAt !== "number" ||
typeof cacheEntry.staleAt !== "number" ||
(cacheEntry.flags && !parseAPIFlagsResponse(cacheEntry.flags))
) {
return;
}
cacheData[key] = {
expireAt: cacheEntry.expireAt,
staleAt: cacheEntry.staleAt,
flags: cacheEntry.flags,
};
}
return cacheData;
}
/**
* Check if the given item is an object.
*
* @param item - The item to check.
* @returns `true` if the item is an object, `false` otherwise.
**/
export function isObject(item: any): item is Record<string, any> {
return (item && typeof item === "object" && !Array.isArray(item)) || false;
}
```
--------------------------------------------------------------------------------
/packages/node-sdk/examples/express/app.ts:
--------------------------------------------------------------------------------
```typescript
import reflag from "./reflag";
import express from "express";
import { BoundReflagClient } from "../src";
// Augment the Express types to include the `reflagUser` property on the `res.locals` object
// This will allow us to access the ReflagClient instance in our route handlers
// without having to pass it around manually
declare global {
namespace Express {
interface Locals {
reflagUser: BoundReflagClient;
}
}
}
const app = express();
app.use(express.json());
app.use((req, res, next) => {
// Extract the user and company IDs from the request headers
// You'll want to use a proper authentication and identification
// mechanism in a real-world application
const { user, company } = extractReflagContextFromHeader(req);
// Create a new BoundReflagClient instance by calling the `bindClient` method on a `ReflagClient` instance
// This will create a new instance that is bound to the user/company given.
const reflagUser = reflag.bindClient({ user, company });
// Store the BoundReflagClient instance in the `res.locals` object so we can access it in our route handlers
res.locals.reflagUser = reflagUser;
next();
});
export const todos = ["Buy milk", "Walk the dog"];
app.get("/", (_req, res) => {
res.locals.reflagUser.track("Front Page Viewed");
res.json({ message: "Ready to manage some TODOs!" });
});
// Return todos if the feature is enabled for the user
app.get("/todos", async (_req, res) => {
// We use the `getFlag` method to check if the user has the "show-todos" feature enabled.
// Note that "show-todos" is a feature that we defined in the `Flags` interface in the `reflag.ts` file.
// and that the indexing for feature name below is type-checked at compile time.
const { isEnabled, track } = res.locals.reflagUser.getFlag("show-todos");
if (isEnabled) {
track();
// You can instead also send any custom event if you prefer, including attributes.
// res.locals.reflagUser.track("Todo's viewed", { attributes: { access: "api" } });
return res.json({ todos });
}
// Return no todos if the feature is disabled for the user
return res.json({ todos: [] });
});
app.post("/todos", (req, res) => {
const { todo } = req.body;
if (typeof todo !== "string") {
return res.status(400).json({ error: "Invalid todo" });
}
const { track, isEnabled, config } =
res.locals.reflagUser.getFlag("create-todos");
// Check if the user has the "create-todos" feature enabled
if (isEnabled) {
// Check if the todo is at least N characters long
if (todo.length < config.payload.minimumLength) {
return res
.status(400)
.json({ error: "Todo must be at least 5 characters long" });
}
// Track the feature usage
track();
todos.push(todo);
return res.status(201).json({ todo });
}
res
.status(403)
.json({ error: "You do not have access to this feature yet!" });
});
app.delete("/todos/:idx", (req, res) => {
const idx = parseInt(req.params.idx);
if (isNaN(idx) || idx < 0 || idx >= todos.length) {
return res.status(400).json({ error: "Invalid index" });
}
const { track, isEnabled } = res.locals.reflagUser.getFlag("delete-todos");
if (isEnabled) {
todos.splice(idx, 1);
track();
res.json({});
}
res
.status(403)
.json({ error: "You do not have access to this feature yet!" });
});
app.get("/features", async (_req, res) => {
const features = await res.locals.reflagUser.getFlagsRemote();
res.json(features);
});
export default app;
function extractReflagContextFromHeader(req: express.Request) {
const user = req.headers["x-reflag-user-id"]
? {
id: req.headers["x-reflag-user-id"] as string,
role: req.headers["x-reflag-is-admin"] ? "admin" : "user",
}
: undefined;
const company = req.headers["x-reflag-company-id"]
? {
id: req.headers["x-reflag-company-id"] as string,
betaUser: !!req.headers["x-reflag-company-beta-user"],
}
: undefined;
return { user, company };
}
```
--------------------------------------------------------------------------------
/packages/cli/index.ts:
--------------------------------------------------------------------------------
```typescript
#!/usr/bin/env node
import chalk from "chalk";
import { program } from "commander";
import ora from "ora";
import { registerAppCommands } from "./commands/apps.js";
import { registerAuthCommands } from "./commands/auth.js";
import { registerFlagCommands } from "./commands/flags.js";
import { registerInitCommand } from "./commands/init.js";
import { registerMcpCommand } from "./commands/mcp.js";
import { registerNewCommand } from "./commands/new.js";
import { registerRulesCommand } from "./commands/rules.js";
import { bootstrap, getReflagUser } from "./services/bootstrap.js";
import { authStore } from "./stores/auth.js";
import { configStore } from "./stores/config.js";
import { commandName } from "./utils/commander.js";
import { handleError } from "./utils/errors.js";
import {
apiKeyOption,
apiUrlOption,
baseUrlOption,
debugOption,
} from "./utils/options.js";
import { stripTrailingSlash } from "./utils/urls.js";
import { checkLatest as checkLatestVersion } from "./utils/version.js";
const skipBootstrapCommands = [/^login/, /^logout/, /^rules/];
type Options = {
debug?: boolean;
baseUrl?: string;
apiUrl?: string;
apiKey?: string;
};
async function main() {
// Start a version check in the background
// unhandled promise rejection can happen even without the `await`
// so we need a `catch` here.
const cliVersionCheckPromise = checkLatestVersion().catch(() => ({
latestVersion: "unknown",
currentVersion: "unknown",
isNewerAvailable: false,
}));
// Must load tokens and config before anything else
await authStore.initialize();
await configStore.initialize();
// Global options
program.addOption(debugOption);
program.addOption(baseUrlOption);
program.addOption(apiUrlOption);
program.addOption(apiKeyOption);
// Pre-action hook
program.hook("preAction", async (_, actionCommand) => {
const {
debug,
baseUrl,
apiUrl,
apiKey: explicitApiKey,
} = program.opts<Options>();
const cleanedBaseUrl = stripTrailingSlash(baseUrl?.trim());
const cleanedApiUrl = stripTrailingSlash(apiUrl?.trim());
const apiKey = explicitApiKey ?? process.env.REFLAG_API_KEY;
if (typeof apiKey === "string" && apiKey.length > 0) {
console.info(
chalk.yellow(
"API key supplied. Using it instead of normal personal authentication.",
),
);
authStore.useApiKey(apiKey);
}
// Set baseUrl and apiUrl in config store, will skip if undefined
configStore.setConfig({
baseUrl: cleanedBaseUrl,
apiUrl: cleanedApiUrl || (cleanedBaseUrl && `${cleanedBaseUrl}/api`),
});
// Skip bootstrapping for commands that don't require it
if (
!skipBootstrapCommands.some((cmd) => cmd.test(commandName(actionCommand)))
) {
const spinner = ora("Bootstrapping...").start();
try {
// Load bootstrap data if not already loaded
await bootstrap();
spinner.stop();
} catch (error) {
spinner.fail("Bootstrap failed.");
handleError(error, "Connect");
}
}
const { latestVersion, currentVersion, isNewerAvailable } =
await cliVersionCheckPromise;
if (isNewerAvailable) {
console.info(
`A new version of the CLI is available: ${chalk.yellow(
currentVersion,
)} -> ${chalk.green(latestVersion)}. Update to ensure you have the latest features and bug fixes.`,
);
}
if (debug) {
console.debug(chalk.cyan("\nDebug mode enabled."));
const user = getReflagUser();
console.debug(`Logged in as ${chalk.cyan(user.name ?? user.email)}.`);
console.debug(
"Reading config from:",
chalk.cyan(configStore.getConfigPath()),
);
console.table(configStore.getConfig());
}
});
// Main program
registerNewCommand(program);
registerInitCommand(program);
registerAuthCommands(program);
registerAppCommands(program);
registerFlagCommands(program);
registerMcpCommand(program);
registerRulesCommand(program);
program.parse(process.argv);
}
void main();
```
--------------------------------------------------------------------------------
/packages/browser-sdk/test/hooksManager.test.ts:
--------------------------------------------------------------------------------
```typescript
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CompanyContext, UserContext } from "../src";
import { CheckEvent, RawFlags } from "../src/flag/flags";
import { HooksManager } from "../src/hooksManager";
describe("HookManager", () => {
let hookManager: HooksManager;
beforeEach(() => {
hookManager = new HooksManager();
});
it("should add and trigger `check` hooks (is-enabled)", () => {
const callback = vi.fn();
hookManager.addHook("check", callback);
const checkEvent: CheckEvent = {
action: "check-is-enabled",
key: "test-key",
value: true,
};
hookManager.trigger("check", checkEvent);
expect(callback).toHaveBeenCalledWith(checkEvent);
});
it("should add and trigger `check` hooks (config)", () => {
const callback = vi.fn();
hookManager.addHook("check", callback);
const checkEvent: CheckEvent = {
action: "check-config",
key: "test-key",
value: { key: "key", payload: "payload" },
};
hookManager.trigger("check", checkEvent);
expect(callback).toHaveBeenCalledWith(checkEvent);
});
it("should add and trigger `flagsUpdated` hooks", () => {
const callback = vi.fn();
hookManager.addHook("flagsUpdated", callback);
const flags: RawFlags = {
/* mock RawFlags data */
};
hookManager.trigger("flagsUpdated", flags);
expect(callback).toHaveBeenCalledWith(flags);
});
it("should add and trigger `track` hooks", () => {
const callback = vi.fn();
const user: UserContext = { id: "user-id", name: "user-name" };
const company: CompanyContext = { id: "company-id", name: "company-name" };
hookManager.addHook("track", callback);
const eventName = "test-event";
const attributes = { key: "value" };
hookManager.trigger("track", { eventName, attributes, user, company });
expect(callback).toHaveBeenCalledWith({
eventName,
attributes,
user,
company,
});
});
it("should add and trigger `user` hooks", () => {
const callback = vi.fn();
hookManager.addHook("user", callback);
const user = { id: "user-id", name: "user-name" };
hookManager.trigger("user", user);
expect(callback).toHaveBeenCalledWith(user);
});
it("should add and trigger `company` hooks", () => {
const callback = vi.fn();
hookManager.addHook("company", callback);
const company = { id: "company-id", name: "company-name" };
hookManager.trigger("company", company);
expect(callback).toHaveBeenCalledWith(company);
});
it("should handle multiple hooks of the same type", () => {
const callback1 = vi.fn();
const callback2 = vi.fn();
hookManager.addHook("check", callback1);
hookManager.addHook("check", callback2);
const checkEvent: CheckEvent = {
action: "check-is-enabled",
key: "test-key",
value: true,
};
hookManager.trigger("check", checkEvent);
expect(callback1).toHaveBeenCalledWith(checkEvent);
expect(callback2).toHaveBeenCalledWith(checkEvent);
});
it("should remove the given hook and no other hooks", () => {
const callback1 = vi.fn();
const callback2 = vi.fn();
hookManager.addHook("check", callback1);
hookManager.addHook("check", callback2);
hookManager.removeHook("check", callback1);
const checkEvent: CheckEvent = {
action: "check-is-enabled",
key: "test-key",
value: true,
};
hookManager.trigger("check", checkEvent);
expect(callback1).not.toHaveBeenCalled();
expect(callback2).toHaveBeenCalledWith(checkEvent);
});
it("should remove the hook using the function returned from addHook", () => {
const callback1 = vi.fn();
const callback2 = vi.fn();
const removeHook1 = hookManager.addHook("check", callback1);
hookManager.addHook("check", callback2);
removeHook1();
const checkEvent: CheckEvent = {
action: "check-is-enabled",
key: "test-key",
value: true,
};
hookManager.trigger("check", checkEvent);
expect(callback1).not.toHaveBeenCalled();
expect(callback2).toHaveBeenCalledWith(checkEvent);
});
});
```
--------------------------------------------------------------------------------
/packages/node-sdk/test/fetch-http-client.test.ts:
--------------------------------------------------------------------------------
```typescript
import { afterEach, describe, expect, it, vi } from "vitest";
import { API_TIMEOUT_MS } from "../src/config";
import fetchClient from "../src/fetch-http-client";
// mock environment variables
vi.mock("../src/config", () => ({ API_TIMEOUT_MS: 100 }));
describe("fetchClient", () => {
const url = "https://example.com/api";
const headers = { "Content-Type": "application/json" };
afterEach(() => {
vi.resetAllMocks();
});
it("should make a POST request and return the response", async () => {
const body = { key: "value" };
const response = { ok: true, status: 200, body: { success: true } };
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
success: true,
}),
} as Response);
const result = await fetchClient.post<typeof body, typeof response>(
url,
headers,
body,
);
expect(result).toEqual(response);
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(global.fetch).toHaveBeenCalledWith(
url,
expect.objectContaining({
method: "post",
headers,
body: JSON.stringify(body),
signal: expect.any(AbortSignal),
}),
);
});
it("should make a GET request and return the response", async () => {
const response = { ok: true, status: 200, body: { success: true } };
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
success: true,
}),
} as Response);
const result = await fetchClient.get<typeof response>(url, headers);
expect(result).toEqual(response);
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(global.fetch).toHaveBeenCalledWith(
url,
expect.objectContaining({
method: "get",
headers,
signal: expect.any(AbortSignal),
}),
);
});
it("should timeout a POST request that takes too long", async () => {
global.fetch = vi
.fn()
.mockImplementation(
() =>
new Promise((resolve) =>
setTimeout(
() => resolve({ ok: true, json: async () => ({}) }),
API_TIMEOUT_MS + 100,
),
),
);
await fetchClient.post(url, headers, {});
expect(vi.mocked(global.fetch).mock.calls[0][1]?.signal?.aborted).toBe(
true,
);
});
it("should timeout a GET request that takes too long", async () => {
global.fetch = vi
.fn()
.mockImplementation(
() =>
new Promise((resolve) =>
setTimeout(
() => resolve({ ok: true, json: async () => ({}) }),
API_TIMEOUT_MS + 100,
),
),
);
await fetchClient.get(url, headers);
expect(vi.mocked(global.fetch).mock.calls[0][1]?.signal?.aborted).toBe(
true,
);
});
it("should handle POST non-20x responses", async () => {
const response = {
ok: false,
status: 400,
body: { error: "Something went wrong" },
};
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 400,
json: async () => ({ error: "Something went wrong" }),
} as Response);
const result = await fetchClient.post(url, headers, {});
expect(result).toEqual(response);
});
it("should handle GET non-20x responses", async () => {
const response = {
ok: false,
status: 400,
body: { error: "Something went wrong" },
};
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 400,
json: async () => ({ error: "Something went wrong" }),
} as Response);
const result = await fetchClient.get(url, headers);
expect(result).toEqual(response);
});
it("should not handle POST exceptions", async () => {
global.fetch = vi.fn().mockRejectedValue(new Error("Network error"));
await expect(fetchClient.post(url, headers, {})).rejects.toThrow(
"Network error",
);
});
it("should not handle GET exceptions", async () => {
global.fetch = vi.fn().mockRejectedValue(new Error("Network error"));
await expect(fetchClient.get(url, headers)).rejects.toThrow(
"Network error",
);
});
});
```
--------------------------------------------------------------------------------
/packages/cli/utils/gen.ts:
--------------------------------------------------------------------------------
```typescript
import { camelCase, kebabCase, pascalCase, snakeCase } from "change-case";
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, join } from "node:path";
import { Flag, RemoteConfig } from "../services/flags.js";
import { JSONToType, quoteKey } from "./json.js";
export type GenFormat = "react" | "node";
// Keep in sync with Reflag main repo
export const KeyFormats = [
"custom",
"pascalCase",
"camelCase",
"snakeCaseUpper",
"snakeCaseLower",
"kebabCaseUpper",
"kebabCaseLower",
] as const;
export type KeyFormat = (typeof KeyFormats)[number];
type KeyFormatPattern = {
transform: (key: string) => string;
regex: RegExp;
message: string;
};
export const KeyFormatPatterns: Record<KeyFormat, KeyFormatPattern> = {
custom: {
transform: (key) => key?.trim(),
regex: /^[\p{L}\p{N}\p{P}\p{S}\p{Z}]+$/u,
message:
"Key must contain only letters, numbers, punctuation, symbols, or spaces.",
},
pascalCase: {
transform: (key) => pascalCase(key),
regex: /^[\p{Lu}][\p{L}\p{N}]*$/u,
message:
"Key must start with uppercase letter and contain only letters and numbers.",
},
camelCase: {
transform: (key) => camelCase(key),
regex: /^[\p{Ll}][\p{L}\p{N}]*$/u,
message:
"Key must start with lowercase letter and contain only letters and numbers.",
},
snakeCaseUpper: {
transform: (key) => snakeCase(key).toUpperCase(),
regex: /^[\p{Lu}][\p{Lu}\p{N}]*(?:_[\p{Lu}\p{N}]+)*$/u,
message: "Key must be uppercase with words separated by underscores.",
},
snakeCaseLower: {
transform: (key) => snakeCase(key).toLowerCase(),
regex: /^[\p{Ll}][\p{Ll}\p{N}]*(?:_[\p{Ll}\p{N}]+)*$/u,
message: "Key must be lowercase with words separated by underscores.",
},
kebabCaseUpper: {
transform: (key) => kebabCase(key).toUpperCase(),
regex: /^[\p{Lu}][\p{Lu}\p{N}]*(?:-[\p{Lu}\p{N}]+)*$/u,
message: "Key must be uppercase with words separated by hyphens.",
},
kebabCaseLower: {
transform: (key) => kebabCase(key).toLowerCase(),
regex: /^[\p{Ll}][\p{Ll}\p{N}]*(?:-[\p{Ll}\p{N}]+)*$/u,
message: "Key must be lowercase with words separated by hyphens.",
},
};
export function indentLines(
str: string,
indent = 2,
lineBreak = "\n",
trim = false,
): string {
const indentStr = " ".repeat(indent);
return str
.split(lineBreak)
.map((line) => `${indentStr}${trim ? line.trim() : line}`)
.join(lineBreak);
}
export function genFlagKey(input: string, format: KeyFormat): string {
return KeyFormatPatterns[format].transform(input);
}
export function genRemoteConfig(remoteConfigs?: RemoteConfig[]) {
const variants = remoteConfigs?.[0]?.variants;
if (!variants?.length) return;
return JSONToType(
remoteConfigs![0].variants?.map(({ variant: { payload } }) => payload),
);
}
export function genTypes(flags: Flag[], format: GenFormat = "react") {
const configDefs = new Map<string, { name: string; definition: string }>();
flags.forEach(({ key, name, remoteConfigs }) => {
const definition = genRemoteConfig(remoteConfigs);
if (!definition) {
return;
}
const configName = `${pascalCase(name)}ConfigPayload`;
configDefs.set(key, { name: configName, definition });
});
return /* ts */ `
// DO NOT EDIT THIS FILE. IT IS GENERATED BY THE REFLAG CLI AND WILL BE OVERWRITTEN.
// eslint-disable
// prettier-ignore
import "@reflag/${format}-sdk";
declare module "@reflag/${format}-sdk" {
export interface Flags {
${flags
.map(({ key }) => {
const config = configDefs.get(key);
return indentLines(
`${quoteKey(key)}: ${config?.definition ? `{ config: { payload: ${config.name} } }` : "boolean"};`,
4,
);
})
.join("\n")}
}
${Array.from(configDefs.values())
.map(({ name, definition }) => {
return indentLines(`export type ${name} = ${definition}`);
})
.join("\n\n")}
}
`.trim();
}
export async function writeTypesToFile(
types: string,
outPath: string,
projectPath: string,
) {
const fullPath = isAbsolute(outPath) ? outPath : join(projectPath, outPath);
await mkdir(dirname(fullPath), { recursive: true });
await writeFile(fullPath, types);
return fullPath;
}
```
--------------------------------------------------------------------------------
/packages/node-sdk/src/config.ts:
--------------------------------------------------------------------------------
```typescript
import { readFileSync } from "fs";
import { version } from "../package.json";
import { LOG_LEVELS } from "./types";
import { isObject, ok } from "./utils";
export const API_BASE_URL = "https://front.reflag.com";
export const SDK_VERSION_HEADER_NAME = "reflag-sdk-version";
export const SDK_VERSION = `node-sdk/${version}`;
export const API_TIMEOUT_MS = 10000;
export const END_FLUSH_TIMEOUT_MS = 5000;
export const REFLAG_LOG_PREFIX = "[Reflag]";
export const FLAG_EVENT_RATE_LIMITER_WINDOW_SIZE_MS = 60 * 1000;
export const FLAGS_REFETCH_MS = 60 * 1000; // re-fetch every 60 seconds
export const BATCH_MAX_SIZE = 100;
export const BATCH_INTERVAL_MS = 10 * 1000;
function parseOverrides(config: object | undefined) {
if (!config) return {};
if ("flagOverrides" in config && isObject(config.flagOverrides)) {
Object.entries(config.flagOverrides).forEach(([key, value]) => {
ok(
typeof value === "boolean" || isObject(value),
`invalid type "${typeof value}" for key ${key}, expected boolean or object`,
);
if (isObject(value)) {
ok(
"isEnabled" in value && typeof value.isEnabled === "boolean",
`invalid type "${typeof value.isEnabled}" for key ${key}.isEnabled, expected boolean`,
);
ok(
value.config === undefined || isObject(value.config),
`invalid type "${typeof value.config}" for key ${key}.config, expected object or undefined`,
);
if (isObject(value.config)) {
ok(
"key" in value.config && typeof value.config.key === "string",
`invalid type "${typeof value.config.key}" for key ${key}.config.key, expected string`,
);
}
}
});
return config.flagOverrides;
}
return {};
}
function loadConfigFile(file: string) {
const configJson = readFileSync(file, "utf-8");
const config = JSON.parse(configJson);
ok(typeof config === "object", "config must be an object");
const { secretKey, logLevel, offline, host, apiBaseUrl } = config;
ok(
typeof secretKey === "undefined" || typeof secretKey === "string",
"secret must be a string",
);
ok(
typeof apiBaseUrl === "undefined" || typeof apiBaseUrl === "string",
"apiBaseUrl must be a string",
);
ok(
typeof logLevel === "undefined" ||
(typeof logLevel === "string" && LOG_LEVELS.includes(logLevel as any)),
`logLevel must one of ${LOG_LEVELS.join(", ")}`,
);
ok(
typeof offline === "undefined" || typeof offline === "boolean",
"offline must be a boolean",
);
return {
flagOverrides: parseOverrides(config),
secretKey,
logLevel,
offline,
apiBaseUrl: host ?? apiBaseUrl,
};
}
function loadEnvVars() {
const secretKey = process.env.REFLAG_SECRET_KEY;
const enabledFlags = process.env.REFLAG_FLAGS_ENABLED;
const disabledFlags = process.env.REFLAG_FLAGS_DISABLED;
const logLevel = process.env.REFLAG_LOG_LEVEL;
const apiBaseUrl = process.env.REFLAG_API_BASE_URL;
const offline =
process.env.REFLAG_OFFLINE !== undefined
? ["true", "on"].includes(process.env.REFLAG_OFFLINE)
: undefined;
let flagOverrides: Record<string, boolean> = {};
if (enabledFlags) {
flagOverrides = enabledFlags.split(",").reduce(
(acc, f) => {
const key = f.trim();
if (key) acc[key] = true;
return acc;
},
{} as Record<string, boolean>,
);
}
if (disabledFlags) {
flagOverrides = {
...flagOverrides,
...disabledFlags.split(",").reduce(
(acc, f) => {
const key = f.trim();
if (key) acc[key] = false;
return acc;
},
{} as Record<string, boolean>,
),
};
}
return { secretKey, flagOverrides, logLevel, offline, apiBaseUrl };
}
export function loadConfig(file?: string) {
let fileConfig;
if (file) {
fileConfig = loadConfigFile(file);
}
const envConfig = loadEnvVars();
return {
secretKey: envConfig.secretKey || fileConfig?.secretKey,
logLevel: envConfig.logLevel || fileConfig?.logLevel,
offline: envConfig.offline ?? fileConfig?.offline,
apiBaseUrl: envConfig.apiBaseUrl ?? fileConfig?.apiBaseUrl,
flagOverrides: {
...fileConfig?.flagOverrides,
...envConfig.flagOverrides,
},
};
}
```
--------------------------------------------------------------------------------
/packages/browser-sdk/src/toolbar/Flags.tsx:
--------------------------------------------------------------------------------
```typescript
import { Fragment, h } from "preact";
import { Switch } from "./Switch";
import { FlagItem } from "./Toolbar";
const isFound = (flagKey: string, searchQuery: string | null) => {
return flagKey.toLocaleLowerCase().includes(searchQuery ?? "");
};
export function FlagsTable({
flags,
searchQuery,
appBaseUrl,
setIsEnabledOverride,
}: {
flags: FlagItem[];
searchQuery: string | null;
appBaseUrl: string;
setIsEnabledOverride: (key: string, isEnabled: boolean | null) => void;
}) {
const hasFlags = flags.length > 0;
const hasShownFlags = flags.some((flag) =>
isFound(flag.flagKey, searchQuery),
);
// List flags that match the search query first then alphabetically
const searchedFlags =
searchQuery === null
? flags
: [...flags].sort((a, b) => {
const aMatches = isFound(a.flagKey, searchQuery);
const bMatches = isFound(b.flagKey, searchQuery);
// If both match or both don't match, sort alphabetically
if (aMatches === bMatches) {
const aStartsWith = a.flagKey
.toLocaleLowerCase()
.startsWith(searchQuery);
const bStartsWith = b.flagKey
.toLocaleLowerCase()
.startsWith(searchQuery);
// If one starts with search query and the other doesn't, prioritize the one that starts with it
if (aStartsWith && !bStartsWith) return -1;
if (bStartsWith && !aStartsWith) return 1;
// Otherwise sort alphabetically
return a.flagKey.localeCompare(b.flagKey);
}
// Otherwise, matching flags come first
return aMatches ? -1 : 1;
});
return (
<Fragment>
{(!hasFlags || !hasShownFlags) && (
<div class="flags-table-empty">
No flags {hasFlags ? `matching "${searchQuery}"` : "found"}
</div>
)}
<table class="flags-table">
<tbody>
{searchedFlags.map((flag, index) => (
<FlagRow
key={flag.flagKey}
appBaseUrl={appBaseUrl}
flag={flag}
index={index}
isNotVisible={
searchQuery !== null && !isFound(flag.flagKey, searchQuery)
}
setEnabledOverride={(override) =>
setIsEnabledOverride(flag.flagKey, override)
}
/>
))}
</tbody>
</table>
</Fragment>
);
}
function FlagRow({
setEnabledOverride,
appBaseUrl,
flag,
index,
isNotVisible,
}: {
flag: FlagItem;
appBaseUrl: string;
setEnabledOverride: (isEnabled: boolean | null) => void;
index: number;
isNotVisible: boolean;
}) {
const isEnabledOverride = flag.isEnabledOverride !== null;
return (
<tr
key={flag.flagKey}
class={["flag-row", isNotVisible ? "not-visible" : undefined].join(" ")}
>
<td class="flag-name-cell">
<a
class="flag-link"
href={`${appBaseUrl}/env-current/flags/by-key/${flag.flagKey}`}
rel="noreferrer"
tabIndex={index + 1}
target="_blank"
>
{flag.flagKey}
</a>
</td>
<td class="flag-reset-cell">
{isEnabledOverride ? (
<Reset setEnabledOverride={setEnabledOverride} tabIndex={index + 1} />
) : null}
</td>
<td class="flag-switch-cell">
<Switch
checked={flag.isEnabledOverride ?? flag.isEnabled}
tabIndex={index + 1}
onChange={(e) => {
const isChecked = e.currentTarget.checked;
setEnabledOverride(!isEnabledOverride ? isChecked : null);
}}
/>
</td>
</tr>
);
}
export function FlagSearch({ onSearch }: { onSearch: (val: string) => void }) {
return (
<input
class="search-input"
placeholder="Search flags"
tabIndex={0}
type="search"
autoFocus
onInput={(s) => onSearch(s.currentTarget.value)}
/>
);
}
function Reset({
setEnabledOverride,
...props
}: {
setEnabledOverride: (isEnabled: boolean | null) => void;
} & h.JSX.HTMLAttributes<HTMLAnchorElement>) {
return (
<a
class="reset"
href=""
onClick={(e) => {
e.preventDefault();
setEnabledOverride(null);
}}
{...props}
>
reset
</a>
);
}
```
--------------------------------------------------------------------------------
/packages/node-sdk/test/periodicallyUpdatingCache.test.ts:
--------------------------------------------------------------------------------
```typescript
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import cache from "../src/periodicallyUpdatingCache";
import { Logger } from "../src/types";
describe("cache", () => {
let fn: () => Promise<number>;
let logger: Logger;
beforeAll(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterAll(() => {
vi.useRealTimers();
});
beforeEach(() => {
fn = vi.fn().mockResolvedValue(42);
logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
});
it("should update the cached value when refreshing", async () => {
const cached = cache(1000, 2000, logger, fn);
const result = await cached.refresh();
expect(result).toBe(42);
expect(logger.debug).toHaveBeenCalledWith(
expect.stringMatching("updated cached value"),
42,
);
});
it("should not allow multiple refreses at the same time", async () => {
const cached = cache(1000, 2000, logger, fn);
void cached.refresh();
void cached.refresh();
void cached.refresh();
await cached.refresh();
expect(fn).toHaveBeenCalledTimes(1);
expect(logger.debug).toHaveBeenNthCalledWith(
1,
expect.stringMatching("updated cached value"),
42,
);
void cached.refresh();
await cached.refresh();
expect(fn).toHaveBeenCalledTimes(2);
expect(logger.debug).toHaveBeenNthCalledWith(
2,
expect.stringMatching("updated cached value"),
42,
);
});
it("should warn if the cached value is stale", async () => {
const cached = cache(1000, 2000, logger, fn);
await cached.refresh();
vi.advanceTimersByTime(2500);
const result = cached.get();
expect(result).toBe(42);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringMatching("cached value is stale"),
{
age: expect.any(Number),
cachedValue: 42,
},
);
});
it("should update the cached value after ttl", async () => {
const newValue = 84;
fn = vi.fn().mockResolvedValueOnce(42).mockResolvedValueOnce(newValue);
const cached = cache(1000, 2000, logger, fn);
const first = await cached.refresh();
expect(first).toBe(42);
expect(fn).toHaveBeenCalledTimes(1);
await vi.advanceTimersToNextTimerAsync();
const second = cached.get();
expect(second).toBe(newValue);
expect(fn).toHaveBeenCalledTimes(2);
expect(logger.debug).toHaveBeenCalledWith(
expect.stringMatching("updated cached value"),
newValue,
);
});
it("should handle update failures gracefully", async () => {
const error = new Error("update failed");
fn = vi.fn().mockRejectedValueOnce(error).mockResolvedValueOnce(42);
const cached = cache(1000, 2000, logger, fn);
const first = await cached.refresh();
expect(first).toBeUndefined();
expect(logger.error).toHaveBeenCalledWith(
expect.stringMatching("failed to update cached value"),
error,
);
expect(fn).toHaveBeenCalledTimes(1);
await vi.advanceTimersToNextTimerAsync();
expect(fn).toHaveBeenCalledTimes(2);
expect(logger.debug).toHaveBeenCalledWith(
expect.stringMatching("updated cached value"),
42,
);
const second = cached.get();
expect(second).toBe(42);
});
it("should retain the cached value if the new value is undefined", async () => {
fn = vi.fn().mockResolvedValueOnce(42).mockResolvedValueOnce(undefined);
const cached = cache(1000, 2000, logger, fn);
await cached.refresh();
await vi.advanceTimersToNextTimerAsync();
const second = cached.get();
expect(second).toBe(42);
vi.advanceTimersByTime(2500);
const result = cached.get();
expect(result).toBe(42);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringMatching("cached value is stale"),
{
age: expect.any(Number),
cachedValue: 42,
},
);
});
it("should not update if cached value is still valid", async () => {
const cached = cache(1000, 2000, logger, fn);
const first = await cached.refresh();
vi.advanceTimersByTime(500);
const second = cached.get();
expect(first).toBe(second);
expect(logger.debug).toHaveBeenCalledTimes(1); // Only one update call
});
afterEach(() => {
vi.clearAllTimers();
vi.restoreAllMocks();
});
});
```
--------------------------------------------------------------------------------
/packages/browser-sdk/src/toolbar/Toolbar.css:
--------------------------------------------------------------------------------
```css
/* Animations */
@keyframes bounceInUp {
from,
60%,
75%,
90%,
to {
animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);
}
from {
opacity: 0;
transform: translate3d(0, 50px, 0) scaleY(2);
}
60% {
opacity: 1;
transform: translate3d(0, -6px, 0) scaleY(0.9);
}
75% {
transform: translate3d(0, 3px, 0) scaleY(0.95);
}
90% {
transform: translate3d(0, -2px, 0) scaleY(0.985);
}
to {
transform: translate3d(0, 0, 0);
}
}
@keyframes gelatine {
from,
to {
transform: scale(1, 1);
}
25% {
transform: scale(0.9, 1.1);
}
50% {
transform: scale(1.1, 0.9);
}
75% {
transform: scale(0.95, 1.05);
}
}
/* Toolbar */
.toolbar {
--brand300: #9cc4d3;
--brand400: #77adc1;
--gray500: #787c91;
--gray600: #3c3d49;
--gray700: #22232a;
--gray800: #17181c;
--gray900: #0e0e11;
--gray950: #09090b;
--black: #1e1f24;
--white: white;
--bg-color: var(--gray900);
--bg-light-color: var(--gray700);
--border-color: var(--gray700);
--dimmed-color: var(--gray500);
--logo-color: white;
--text-color: white;
--text-size: 13px;
--text-small-size: 12px;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
Oxygen,
Ubuntu,
Cantarell,
"Open Sans",
"Helvetica Neue",
sans-serif;
font-size: var(--text-size);
}
:focus {
outline: none;
}
.dialog {
color: #ffffff;
box-sizing: border-box;
background: var(--bg-color);
border: 0;
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.15),
0 4px 6px -2px rgba(0, 0, 0, 0.1),
0 -1px rgba(255, 255, 255, 0.1),
0 0 0 1px var(--border-color);
border-radius: 7px;
z-index: 999999;
min-width: 240px;
padding: 0;
--visible-flags: 15;
max-height: min(
calc(100vh - 36px - 35px),
calc(45px + (var(--visible-flags) * 27px))
);
height: auto;
&[open] {
display: flex;
flex-direction: column;
}
}
.dialog-content {
overflow-y: auto;
max-height: 100%;
flex-grow: 1;
margin: 3px 3px 3px 0;
&::-webkit-scrollbar {
width: 8px;
height: 8px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.2);
border-radius: 999px;
transition: background-color 0.1s ease;
&:hover {
background-color: rgba(255, 255, 255, 0.3);
}
}
&::-webkit-scrollbar-button {
display: none;
}
}
.toolbar-toggle {
width: 36px;
height: 36px;
position: fixed;
z-index: 999999;
padding: 0;
margin: 0;
box-sizing: border-box;
color: var(--logo-color);
background: var(--bg-color);
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.15),
0 4px 6px -2px rgba(0, 0, 0, 0.1),
0 -1px rgba(255, 255, 255, 0.1),
0 0 0 1px var(--border-color);
border-radius: 999px;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
animation: bounceInUp 0.2s ease-out;
transition: background 0.1s ease;
&.open {
background: var(--bg-light-color);
}
& .override-indicator {
position: absolute;
top: 1px;
right: 1px;
width: 8px;
height: 8px;
background-color: var(--brand400);
border-radius: 50%;
opacity: 0;
transition: opacity 0.1s ease-in-out;
box-shadow: inset 0px 1px rgba(255, 255, 255, 0.1);
&.show {
opacity: 1;
animation: gelatine 0.5s;
}
}
}
.toolbar-header-button {
background: none;
border: none;
color: var(--dimmed-color);
cursor: pointer;
border-radius: 4px;
transition: background-color 0.1s ease;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
height: 28px;
width: 28px;
&:hover {
background-color: var(--bg-light-color);
color: var(--text-color);
}
&:focus-visible {
outline: 1px solid #fff;
outline-offset: 0px;
}
& + .button-tooltip {
pointer-events: none;
opacity: 0;
background: var(--bg-color);
color: var(--text-color);
padding: 6px 8px;
border-radius: 4px;
font-size: 13px;
}
&:hover + .button-tooltip {
opacity: 1;
}
}
[data-tooltip] {
position: relative;
}
[data-tooltip]:after {
content: attr(data-tooltip);
position: absolute;
right: 100%;
top: 0%;
margin-right: 3px;
user-select: none;
pointer-events: none;
background-color: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 0px 6px;
height: 28px;
line-height: 26px;
color: var(--text-color);
font-size: var(--text-small-size);
font-weight: normal;
width: max-content;
display: none;
box-sizing: border-box;
}
[data-tooltip]:hover:after {
display: block;
}
```
--------------------------------------------------------------------------------
/packages/browser-sdk/test/mocks/handlers.ts:
--------------------------------------------------------------------------------
```typescript
import { DefaultBodyType, http, HttpResponse, StrictRequest } from "msw";
import { RawFlags } from "../../src/flag/flags";
export const testChannel = "testChannel";
export const flagResponse = {
success: true,
features: {
flagA: {
isEnabled: true,
key: "flagA",
targetingVersion: 1,
config: undefined,
ruleEvaluationResults: [false, true],
missingContextFields: ["field1", "field2"],
},
flagB: {
isEnabled: true,
targetingVersion: 11,
key: "flagB",
config: {
version: 12,
key: "gpt3",
payload: { model: "gpt-something", temperature: 0.5 },
ruleEvaluationResults: [true, false, false],
missingContextFields: ["field3"],
},
},
},
};
export const flagsResult = Object.entries(flagResponse.features).reduce(
(acc, [key, flag]) => {
acc[key] = {
...flag!,
config: flag.config,
isEnabledOverride: null,
};
return acc;
},
{} as RawFlags,
);
function checkRequest(request: StrictRequest<DefaultBodyType>) {
const url = new URL(request.url);
const hasKey =
url.searchParams.get("publishableKey") ||
request.headers.get("Authorization");
const hasSdkVersion =
url.searchParams.get("reflag-sdk-version") ||
request.headers.get("reflag-sdk-version");
const valid = hasKey && hasSdkVersion;
if (!valid) {
console.log(
"missing token or sdk: " +
request.url.toString() +
" " +
JSON.stringify(request.headers),
);
}
return valid;
}
const invalidReqResponse = new HttpResponse("missing token or sdk", {
status: 400,
});
export function getFlags({
request,
}: {
request: StrictRequest<DefaultBodyType>;
}) {
if (!checkRequest(request)) return invalidReqResponse;
return HttpResponse.json(flagResponse);
}
export const handlers = [
http.post("https://front.reflag.com/user", async ({ request }) => {
if (!checkRequest(request)) return invalidReqResponse;
const data = await request.json();
if (
typeof data !== "object" ||
!data ||
!data["userId"] ||
!data["attributes"]
) {
return HttpResponse.error();
}
return HttpResponse.json({
success: true,
});
}),
http.post("https://front.reflag.com/company", async ({ request }) => {
if (!checkRequest(request)) return invalidReqResponse;
const data = await request.json();
if (
typeof data !== "object" ||
!data ||
!data["companyId"] ||
!data["attributes"]
) {
return HttpResponse.error();
}
return HttpResponse.json({
success: true,
});
}),
http.post("https://front.reflag.com/event", async ({ request }) => {
if (!checkRequest(request)) return invalidReqResponse;
const data = await request.json();
if (typeof data !== "object" || !data || !data["userId"]) {
return HttpResponse.error();
}
return HttpResponse.json({
success: true,
});
}),
http.post("https://front.reflag.com/features/events", async ({ request }) => {
if (!checkRequest(request)) return invalidReqResponse;
const data = await request.json();
if (typeof data !== "object" || !data || !data["userId"]) {
return HttpResponse.error();
}
return HttpResponse.json({
success: true,
});
}),
http.post("https://front.reflag.com/feedback", async ({ request }) => {
if (!checkRequest(request)) return invalidReqResponse;
const data = await request.json();
if (
typeof data !== "object" ||
!data ||
!data["userId"] ||
typeof data["score"] !== "number" ||
(!data["featureId"] && !data["key"])
) {
return HttpResponse.error();
}
return HttpResponse.json({
success: true,
});
}),
http.get("https://front.reflag.com/features/enabled", getFlags),
http.get("https://front.reflag.com/features/evaluated", getFlags),
http.post(
"https://front.reflag.com/feedback/prompting-init",
({ request }) => {
if (!checkRequest(request)) return invalidReqResponse;
return HttpResponse.json({ success: true, channel: testChannel });
},
),
http.get(
"https://front.reflag.com/feedback/prompting-auth",
({ request }) => {
if (!checkRequest(request)) return invalidReqResponse;
return HttpResponse.json({ success: true, keyName: "keyName" });
},
),
http.post(
"https://livemessaging.bucket.co/keys/keyName/requestToken",
async ({ request }) => {
const data = await request.json();
if (typeof data !== "object") {
return HttpResponse.error();
}
return HttpResponse.json({
success: true,
token: "token",
expires: 1234567890,
});
},
),
];
```
--------------------------------------------------------------------------------
/packages/eslint-config/base.js:
--------------------------------------------------------------------------------
```javascript
const jsPlugin = require("@eslint/js");
const importsPlugin = require("eslint-plugin-import");
const unusedImportsPlugin = require("eslint-plugin-unused-imports");
const sortImportsPlugin = require("eslint-plugin-simple-import-sort");
const { builtinModules } = require("module");
const globals = require("globals");
const tsPlugin = require("@typescript-eslint/eslint-plugin");
const tsParser = require("@typescript-eslint/parser");
const prettierConfig = require("eslint-config-prettier");
const vuePlugin = require("eslint-plugin-vue");
module.exports = [
{
// Blacklisted Folders, including **/node_modules/ and .git/
ignores: ["build/", "**/gen"],
},
{
// All files
files: [
"**/*.js",
"**/*.cjs",
"**/*.mjs",
"**/*.jsx",
"**/*.ts",
"**/*.tsx",
"**/*.vue",
],
plugins: {
import: importsPlugin,
"unused-imports": unusedImportsPlugin,
"simple-import-sort": sortImportsPlugin,
},
languageOptions: {
globals: {
...globals.node,
...globals.browser,
},
parserOptions: {
// Eslint doesn't supply ecmaVersion in `parser.js` `context.parserOptions`
// This is required to avoid ecmaVersion < 2015 error or 'import' / 'export' error
sourceType: "module",
ecmaVersion: "latest",
ecmaFeatures: {
modules: true,
impliedStrict: true,
jsx: true,
},
},
},
settings: {
react: {
version: "detect",
},
"import/parsers": {
// Workaround until import supports flat config
// https://github.com/import-js/eslint-plugin-import/issues/2556
espree: [".js", ".cjs", ".mjs", ".jsx"],
},
"import/resolver": {
typescript: {
alwaysTryTypes: true,
},
},
},
rules: {
...jsPlugin.configs.recommended.rules,
...importsPlugin.configs.recommended.rules,
// Imports
"unused-imports/no-unused-vars": [
"warn",
{
vars: "all",
varsIgnorePattern: "^_",
args: "after-used",
argsIgnorePattern: "^_",
},
],
"unused-imports/no-unused-imports": ["warn"],
"import/first": ["warn"],
"import/newline-after-import": ["warn"],
"import/no-named-as-default": ["off"],
"simple-import-sort/exports": ["warn"],
"simple-import-sort/imports": [
"warn",
{
groups: [
// Side effect imports.
["^\\u0000"],
// Node.js builtins, react, and third-party packages.
[
`^(${builtinModules.join("|")})(/|$)`,
"^react",
"^(?!@reflag)@?\\w",
],
// Shared reflag packages.
["^@reflag/(.*)$"],
// Path aliased root, parent imports, and just `..`.
["^@/", "^\\.\\.(?!/?$)", "^\\.\\./?$"],
// Relative imports, same-folder imports, and just `.`.
["^\\./(?=.*/)(?!/?$)", "^\\.(?!/?$)", "^\\./?$"],
// Style imports.
["^.+\\.s?css$"],
],
},
],
},
},
{
// TypeScript files
files: ["**/*.ts", "**/*.tsx"],
plugins: {
"@typescript-eslint": tsPlugin,
vue: vuePlugin,
},
languageOptions: {
parser: tsParser,
parserOptions: {
project: "./tsconfig.eslint.json",
},
},
settings: {
...importsPlugin.configs.typescript.settings,
"import/resolver": {
...importsPlugin.configs.typescript.settings["import/resolver"],
typescript: {
project: "./tsconfig.json",
},
},
},
rules: {
...importsPlugin.configs.typescript.rules,
...tsPlugin.configs["eslint-recommended"].overrides[0].rules,
...tsPlugin.configs.recommended.rules,
// Typescript Specific
"@typescript-eslint/no-unused-vars": ["off"], // handled by unused-imports
"@typescript-eslint/explicit-module-boundary-types": ["off"],
"@typescript-eslint/no-floating-promises": ["error"],
"@typescript-eslint/switch-exhaustiveness-check": ["warn"],
"@typescript-eslint/no-non-null-assertion": ["off"],
"@typescript-eslint/no-empty-function": ["warn"],
"@typescript-eslint/no-explicit-any": ["off"],
"@typescript-eslint/no-use-before-define": ["off"],
"@typescript-eslint/no-shadow": ["warn"],
},
},
{
files: ["**/*.tsx"],
rules: {
"react/prop-types": "off",
},
},
{
// Prettier Overrides
files: [
"**/*.js",
"**/*.cjs",
"**/*.mjs",
"**/*.jsx",
"**/*.ts",
"**/*.tsx",
"**/*.vue",
],
rules: {
...prettierConfig.rules,
},
},
];
```
--------------------------------------------------------------------------------
/packages/browser-sdk/src/ui/packages/floating-ui-preact-dom/useFloating.ts:
--------------------------------------------------------------------------------
```typescript
import { computePosition } from "@floating-ui/dom";
import {
useCallback,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "preact/hooks";
import { deepEqual } from "./utils/deepEqual";
import { getDPR } from "./utils/getDPR";
import { roundByDPR } from "./utils/roundByDPR";
import { useLatestRef } from "./utils/useLatestRef";
import type {
ComputePositionConfig,
ReferenceType,
UseFloatingData,
UseFloatingOptions,
UseFloatingReturn,
} from "./types";
/**
* Provides data to position a floating element.
* @see https://floating-ui.com/docs/react
*/
export function useFloating<RT extends ReferenceType = ReferenceType>(
options: UseFloatingOptions = {},
): UseFloatingReturn<RT> {
const {
placement = "bottom",
strategy = "absolute",
middleware = [],
platform,
elements: { reference: externalReference, floating: externalFloating } = {},
transform = true,
whileElementsMounted,
open,
} = options;
const [data, setData] = useState<UseFloatingData>({
x: 0,
y: 0,
strategy,
placement,
middlewareData: {},
isPositioned: false,
});
const [latestMiddleware, setLatestMiddleware] = useState(middleware);
if (!deepEqual(latestMiddleware, middleware)) {
setLatestMiddleware(middleware);
}
const [_reference, _setReference] = useState<RT | null>(null);
const [_floating, _setFloating] = useState<HTMLElement | null>(null);
const setReference = useCallback(
(node: RT | null) => {
if (node != referenceRef.current) {
referenceRef.current = node;
_setReference(node);
}
},
[_setReference],
);
const setFloating = useCallback(
(node: HTMLElement | null) => {
if (node !== floatingRef.current) {
floatingRef.current = node;
_setFloating(node);
}
},
[_setFloating],
);
const referenceEl = (externalReference || _reference) as RT | null;
const floatingEl = externalFloating || _floating;
const referenceRef = useRef<RT | null>(null);
const floatingRef = useRef<HTMLElement | null>(null);
const dataRef = useRef(data);
const whileElementsMountedRef = useLatestRef(whileElementsMounted);
const platformRef = useLatestRef(platform);
const update = useCallback(() => {
if (!referenceRef.current || !floatingRef.current) {
return;
}
const config: ComputePositionConfig = {
placement,
strategy,
middleware: latestMiddleware,
};
if (platformRef.current) {
config.platform = platformRef.current;
}
/*eslint-disable-next-line @typescript-eslint/no-floating-promises*/
computePosition(referenceRef.current, floatingRef.current, config).then(
(positionData) => {
const fullData = { ...positionData, isPositioned: true };
if (isMountedRef.current && !deepEqual(dataRef.current, fullData)) {
dataRef.current = fullData;
setData(fullData);
}
},
);
}, [latestMiddleware, placement, strategy, platformRef]);
useLayoutEffect(() => {
if (open === false && dataRef.current.isPositioned) {
dataRef.current.isPositioned = false;
setData((positionData) => ({ ...positionData, isPositioned: false }));
}
}, [open]);
const isMountedRef = useRef(false);
useLayoutEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
useLayoutEffect(() => {
if (referenceEl) referenceRef.current = referenceEl;
if (floatingEl) floatingRef.current = floatingEl;
if (referenceEl && floatingEl) {
if (whileElementsMountedRef.current) {
return whileElementsMountedRef.current(referenceEl, floatingEl, update);
} else {
return update();
}
}
}, [referenceEl, floatingEl, update, whileElementsMountedRef]);
const refs = useMemo(
() => ({
reference: referenceRef,
floating: floatingRef,
setReference,
setFloating,
}),
[setReference, setFloating],
);
const elements = useMemo(
() => ({ reference: referenceEl, floating: floatingEl }),
[referenceEl, floatingEl],
);
const floatingStyles = useMemo(() => {
const initialStyles = {
position: strategy,
left: 0,
top: 0,
};
if (!elements.floating) {
return initialStyles;
}
const x = roundByDPR(elements.floating, data.x);
const y = roundByDPR(elements.floating, data.y);
if (transform) {
return {
...initialStyles,
transform: `translate(${x}px, ${y}px)`,
...(getDPR(elements.floating) >= 1.5 && { willChange: "transform" }),
};
}
return {
position: strategy,
left: x,
top: y,
};
}, [strategy, transform, elements.floating, data.x, data.y]);
return useMemo(
() => ({
...data,
update,
refs,
elements,
floatingStyles,
}),
[data, update, refs, elements, floatingStyles],
);
}
```
--------------------------------------------------------------------------------
/packages/browser-sdk/src/feedback/ui/StarRating.tsx:
--------------------------------------------------------------------------------
```typescript
import { Fragment, FunctionComponent, h } from "preact";
import { useRef } from "preact/hooks";
import { Dissatisfied } from "../../ui/icons/Dissatisfied";
import { Neutral } from "../../ui/icons/Neutral";
import { Satisfied } from "../../ui/icons/Satisfied";
import { VeryDissatisfied } from "../../ui/icons/VeryDissatisfied";
import { VerySatisfied } from "../../ui/icons/VerySatisfied";
import {
arrow,
offset,
useFloating,
} from "../../ui/packages/floating-ui-preact-dom";
import { FeedbackTranslations } from "./types";
const scores = [
{
color: "var(--reflag-feedback-dialog-rating-1-color, #dd6b20)",
bg: "var(--reflag-feedback-dialog-rating-1-background-color, #fbd38d)",
icon: <VeryDissatisfied />,
getLabel: (t: FeedbackTranslations) => t.ScoreVeryDissatisfiedLabel,
value: 1,
},
{
color: "var(--reflag-feedback-dialog-rating-2-color, #ed8936)",
bg: "var(--reflag-feedback-dialog-rating-2-background-color, #feebc8)",
icon: <Dissatisfied />,
getLabel: (t: FeedbackTranslations) => t.ScoreDissatisfiedLabel,
value: 2,
},
{
color: "var(--reflag-feedback-dialog-rating-3-color, #787c91)",
bg: "var(--reflag-feedback-dialog-rating-3-background-color, #e9e9ed)",
icon: <Neutral />,
getLabel: (t: FeedbackTranslations) => t.ScoreNeutralLabel,
value: 3,
},
{
color: "var(--reflag-feedback-dialog-rating-4-color, #48bb78)",
bg: "var(--reflag-feedback-dialog-rating-4-background-color, #c6f6d5)",
icon: <Satisfied />,
getLabel: (t: FeedbackTranslations) => t.ScoreSatisfiedLabel,
value: 4,
},
{
color: "var(--reflag-feedback-dialog-rating-5-color, #38a169)",
bg: "var(--reflag-feedback-dialog-rating-5-background-color, #9ae6b4)",
icon: <VerySatisfied />,
getLabel: (t: FeedbackTranslations) => t.ScoreVerySatisfiedLabel,
value: 5,
},
] as const;
type ScoreNumber = (typeof scores)[number];
export type StarRatingProps = {
name: string;
selectedValue?: number;
onChange?: h.JSX.GenericEventHandler<HTMLInputElement>;
t: FeedbackTranslations;
};
export const StarRating: FunctionComponent<StarRatingProps> = ({
t,
name,
selectedValue,
onChange,
}) => {
return (
<div class="star-rating">
<style>
{scores.map(
({ bg, color }, index) => `
.star-rating-icons > input:nth-of-type(${
index + 1
}):checked + .button {
border-color: ${color};
}
.star-rating-icons > input:nth-of-type(${
index + 1
}):checked + .button > div {
background-color: ${bg};
}
.star-rating-icons > input:nth-of-type(${
index + 1
}):checked ~ input:nth-of-type(${index + 2}) + .button {
border-left-color: ${color};
}
`,
)}
</style>
<div class="star-rating-icons">
{scores.map((score) => (
<Score
key={score.value}
isSelected={score.value === selectedValue}
name={name}
score={score}
t={t}
onChange={onChange}
/>
))}
</div>
</div>
);
};
const Score = ({
isSelected,
name,
onChange,
score,
t,
}: {
isSelected: boolean;
name: string;
onChange?: h.JSX.GenericEventHandler<HTMLInputElement>;
score: ScoreNumber;
t: FeedbackTranslations;
}) => {
const arrowRef = useRef<HTMLDivElement>(null);
const { refs, floatingStyles, middlewareData } = useFloating({
placement: "top",
middleware: [
offset(4),
arrow({
element: arrowRef,
}),
],
});
return (
<>
<input
defaultChecked={isSelected}
id={`reflag-feedback-score-${score.value}`}
name={name}
type="radio"
value={score.value}
onChange={onChange}
/>
<label
ref={refs.setReference}
aria-label={score.getLabel(t)}
class="button"
for={`reflag-feedback-score-${score.value}`}
style={{ color: score.color }}
>
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
opacity: 0.2, // TODO: fix overflow
zIndex: 1,
}}
/>
<span style={{ zIndex: 2, display: "flex", alignItems: "center" }}>
{score.icon}
</span>
</label>
<div ref={refs.setFloating} class="button-tooltip" style={floatingStyles}>
{score.getLabel(t)}
<div
ref={arrowRef}
class="button-tooltip-arrow"
style={{
left:
middlewareData.arrow?.x != null
? `${middlewareData.arrow.x}px`
: "",
top:
middlewareData.arrow?.y != null
? `${middlewareData.arrow.y}px`
: "",
}}
/>
</div>
</>
);
};
```
--------------------------------------------------------------------------------
/packages/browser-sdk/test/prompts.test.ts:
--------------------------------------------------------------------------------
```typescript
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
parsePromptMessage,
processPromptMessage,
} from "../src/feedback/prompts";
import {
checkPromptMessageCompleted,
markPromptMessageCompleted,
} from "../src/feedback/promptStorage";
vi.mock("../src/feedback/promptStorage", () => {
return {
markPromptMessageCompleted: vi.fn(),
checkPromptMessageCompleted: vi.fn(),
};
});
describe("parsePromptMessage", () => {
test("will not parse invalid messages", () => {
expect(parsePromptMessage(undefined)).toBeUndefined();
expect(parsePromptMessage("invalid")).toBeUndefined();
expect(
parsePromptMessage({ showAfter: Date.now(), showBefore: Date.now() }),
).toBeUndefined();
expect(
parsePromptMessage({
question: "",
showAfter: Date.now(),
showBefore: Date.now(),
}),
).toBeUndefined();
expect(
parsePromptMessage({
question: "hello?",
showBefore: Date.now(),
promptId: "123",
featureId: "123",
}),
).toBeUndefined();
expect(
parsePromptMessage({
question: "hello?",
showAfter: Date.now(),
promptId: "123",
featureId: "123",
}),
).toBeUndefined();
expect(
parsePromptMessage({
question: "hello?",
showAfter: Date.now(),
showBefore: Date.now(),
}),
).toBeUndefined();
expect(
parsePromptMessage({
question: "hello?",
showAfter: Date.now(),
showBefore: Date.now(),
promptId: "123",
}),
).toBeUndefined();
expect(
parsePromptMessage({
question: "hello?",
showAfter: Date.now(),
showBefore: Date.now(),
featureId: "123",
}),
).toBeUndefined();
});
test("will parse valid messages", () => {
const start = Date.parse("2021-01-01T00:00:00.000Z");
const end = Date.parse("2021-01-01T10:00:00.000Z");
expect(
parsePromptMessage({
question: "hello?",
showAfter: start,
showBefore: end,
promptId: "123",
featureId: "456",
}),
).toEqual({
question: "hello?",
showAfter: new Date(start),
showBefore: new Date(end),
promptId: "123",
featureId: "456",
});
});
});
describe("processPromptMessage", () => {
const now = Date.now();
const promptTemplate = {
question: "hello?",
promptId: "123",
featureId: "456",
};
beforeEach(() => {
vi.mocked(checkPromptMessageCompleted).mockReturnValue(false);
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
test("will not process seen prompts", () => {
vi.mocked(checkPromptMessageCompleted).mockReturnValue(true);
const prompt = {
...promptTemplate,
showAfter: new Date(now - 10000),
showBefore: new Date(now + 10000),
};
const showCallback = vi.fn();
expect(processPromptMessage("user", prompt, showCallback)).toBe(false);
expect(showCallback).not.toHaveBeenCalled();
expect(markPromptMessageCompleted).not.toHaveBeenCalled();
});
test("will not process expired prompts", () => {
const prompt = {
...promptTemplate,
showAfter: new Date(now - 10000),
showBefore: new Date(now - 5000),
};
const showCallback = vi.fn();
expect(processPromptMessage("user", prompt, showCallback)).toBe(false);
expect(showCallback).not.toHaveBeenCalled();
expect(markPromptMessageCompleted).not.toHaveBeenCalled();
});
test("will process prompts that are ready to be shown", () => {
const prompt = {
...promptTemplate,
showAfter: new Date(now - 10000),
showBefore: new Date(now + 10000),
};
const showCallback = vi
.fn()
.mockImplementation((_a, _b, actionedCallback) => {
actionedCallback();
});
expect(processPromptMessage("user", prompt, showCallback)).toBe(true);
expect(showCallback).toHaveBeenCalledWith(
"user",
prompt,
expect.any(Function),
);
expect(markPromptMessageCompleted).toHaveBeenCalledOnce();
expect(markPromptMessageCompleted).toBeCalledWith(
"user",
"123",
prompt.showBefore,
);
});
test("will process and delay prompts that are not yet ready to be shown", () => {
const prompt = {
...promptTemplate,
showAfter: new Date(now + 5000),
showBefore: new Date(now + 10000),
};
const showCallback = vi
.fn()
.mockImplementation((_a, _b, actionedCallback) => {
actionedCallback();
});
expect(processPromptMessage("user", prompt, showCallback)).toBe(true);
expect(showCallback).not.toHaveBeenCalled();
expect(localStorage.getItem("prompt-user")).toBeNull();
vi.runAllTimers();
expect(showCallback).toHaveBeenCalledWith(
"user",
prompt,
expect.any(Function),
);
expect(markPromptMessageCompleted).toHaveBeenCalledOnce();
expect(markPromptMessageCompleted).toBeCalledWith(
"user",
"123",
prompt.showBefore,
);
});
});
```
--------------------------------------------------------------------------------
/packages/cli/commands/mcp.ts:
--------------------------------------------------------------------------------
```typescript
import { select } from "@inquirer/prompts";
import chalk from "chalk";
import { Command } from "commander";
import { parse as parseJSON, stringify as stringifyJSON } from "comment-json";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join, relative } from "node:path";
import ora, { type Ora } from "ora";
import {
ConfigPaths,
getServersConfig,
resolveConfigPath,
SupportedEditor,
SupportedEditors,
} from "../services/mcp.js";
import { configStore } from "../stores/config.js";
import { handleError } from "../utils/errors.js";
import { fileExists } from "../utils/file.js";
import { configScopeOption, editorOption } from "../utils/options.js";
export const mcpAction = async (options: {
editor?: SupportedEditor;
scope?: "local" | "global";
}) => {
const config = configStore.getConfig();
let spinner: Ora | undefined;
let selectedEditor = options.editor;
// Select Editor/Client
if (!selectedEditor) {
selectedEditor = await select<SupportedEditor>({
message: "Which editor do you want to configure?",
choices: SupportedEditors.map((value) => ({
value,
name: ConfigPaths[value].name,
})),
});
}
// Determine Config Path
const projectPath = configStore.getProjectPath();
const globalPath = resolveConfigPath(selectedEditor, false);
const localPath = resolveConfigPath(selectedEditor, true);
const fullLocalPath = localPath ? join(projectPath, localPath) : undefined;
if (!globalPath) {
throw new Error(`Unsupported platform for editor: ${selectedEditor}`);
}
let configPathType: "global" | "local" = "global";
if (fullLocalPath) {
if (options.scope) {
configPathType = options.scope;
} else {
configPathType = await select<"global" | "local">({
message: "Configure global or project-local settings?",
choices: [
{
name: `Local (${relative(projectPath, fullLocalPath)})`,
value: "local",
},
{ name: `Global (${globalPath})`, value: "global" },
],
});
}
}
const configPath = configPathType === "local" ? fullLocalPath! : globalPath;
const displayConfigPath =
configPathType === "local" ? relative(projectPath, configPath) : configPath;
// Read/Parse Config File
spinner = ora(
`Reading configuration file: ${chalk.cyan(displayConfigPath)}...`,
).start();
let editorConfig: any = {};
if (await fileExists(configPath)) {
const content = await readFile(configPath, "utf-8");
// Attempt to parse JSON, handle potential comments if needed
try {
editorConfig = parseJSON(content);
} catch {
spinner.fail(
`Failed to parse configuration file ${chalk.cyan(displayConfigPath)}.`,
);
}
spinner.succeed(
`Read configuration file ${chalk.cyan(displayConfigPath)}.`,
);
} else {
spinner.info("Configuration file not found, will create a new one.");
editorConfig = {}; // Initialize empty config if file doesn't exist
}
// Ensure MCP servers object exists
const serversConfig = getServersConfig(
editorConfig,
selectedEditor,
configPathType,
);
// Check for existing Reflag servers
const existingReflagEntries = Object.keys(serversConfig).filter((key) =>
/reflag/i.test(key),
);
// Prompt for Add/Update
let targetEntryKey: string;
const defaultNewKey = `Reflag`;
if (existingReflagEntries.length === 0) {
targetEntryKey = defaultNewKey;
console.log(`Adding new MCP server entry: ${chalk.cyan(targetEntryKey)}`);
} else {
const choices = [
{ name: `Add: ${defaultNewKey}`, value: "add_new" },
...existingReflagEntries.map((key) => ({
name: `Update: ${key}`,
value: key,
})),
];
const choice = await select({
message: "Add a new MCP server or update an existing one?",
choices,
});
if (choice === "add_new") {
targetEntryKey = defaultNewKey;
console.log(`Adding new MCP server entry: ${chalk.cyan(targetEntryKey)}`);
} else {
targetEntryKey = choice;
console.log(
`Updating existing MCP server entry: ${chalk.cyan(targetEntryKey)}`,
);
}
}
// Construct the MCP endpoint URL
const newEntryValue = {
url: config.apiUrl + "/mcp",
};
// Update Config Object
serversConfig[targetEntryKey] = newEntryValue;
// Write Config File
spinner = ora(
`Writing configuration to ${chalk.cyan(displayConfigPath)}...`,
).start();
try {
// Ensure the directory exists before writing
await mkdir(dirname(configPath), { recursive: true });
const configString = stringifyJSON(editorConfig, null, 2);
await writeFile(configPath, configString);
spinner.succeed(
`Configuration updated successfully in ${chalk.cyan(displayConfigPath)}.`,
);
console.log(
chalk.grey(
"You may need to restart your editor for changes to take effect.",
),
);
} catch (error) {
spinner.fail(
`Failed to write configuration file ${chalk.cyan(displayConfigPath)}.`,
);
handleError(error, "MCP Configuration");
}
};
export function registerMcpCommand(cli: Command) {
cli
.command("mcp")
.description("Configure Reflag's remote MCP server for your AI assistant.")
.addOption(editorOption)
.addOption(configScopeOption)
.action(mcpAction);
}
```
--------------------------------------------------------------------------------
/packages/react-sdk/dev/nextjs-flag-demo/app/page.tsx:
--------------------------------------------------------------------------------
```typescript
import Image from "next/image";
import { Flags } from "@/components/Flags";
export default function Home() {
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<div className="z-10 w-full max-w-5xl items-center justify-between font-mono text-sm lg:flex">
<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">
<a
className="pointer-events-none flex place-items-center gap-2 p-8 lg:pointer-events-auto lg:p-0"
href="https://vercel.com?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
By{" "}
<Image
src="/vercel.svg"
alt="Vercel Logo"
className="dark:invert"
width={100}
height={24}
priority
/>
</a>
</div>
</div>
<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]">
<Image
className="relative dark:drop-shadow-[0_0_0.3rem_#ffffff70] dark:invert"
src="/next.svg"
alt="Next.js Logo"
width={180}
height={37}
priority
/>
</div>
<Flags />
<div className="mb-32 grid text-center lg:mb-0 lg:w-full lg:max-w-5xl lg:grid-cols-4 lg:text-left">
<a
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
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"
target="_blank"
rel="noopener noreferrer"
>
<h2 className="mb-3 text-2xl font-semibold">
Docs{" "}
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
->
</span>
</h2>
<p className="m-0 max-w-[30ch] text-sm opacity-50">
Find in-depth information about Next.js features and API.
</p>
</a>
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
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"
target="_blank"
rel="noopener noreferrer"
>
<h2 className="mb-3 text-2xl font-semibold">
Learn{" "}
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
->
</span>
</h2>
<p className="m-0 max-w-[30ch] text-sm opacity-50">
Learn about Next.js in an interactive course with quizzes!
</p>
</a>
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
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"
target="_blank"
rel="noopener noreferrer"
>
<h2 className="mb-3 text-2xl font-semibold">
Templates{" "}
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
->
</span>
</h2>
<p className="m-0 max-w-[30ch] text-sm opacity-50">
Explore starter templates for Next.js.
</p>
</a>
<a
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
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"
target="_blank"
rel="noopener noreferrer"
>
<h2 className="mb-3 text-2xl font-semibold">
Deploy{" "}
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
->
</span>
</h2>
<p className="m-0 max-w-[30ch] text-balance text-sm opacity-50">
Instantly deploy your Next.js site to a shareable URL with Vercel.
</p>
</a>
</div>
</main>
);
}
```
--------------------------------------------------------------------------------
/packages/react-sdk/dev/nextjs-bootstrap-demo/app/page.tsx:
--------------------------------------------------------------------------------
```typescript
import Image from "next/image";
import { Flags } from "@/components/Flags";
export default async function Home() {
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<div className="z-10 w-full max-w-5xl items-center justify-between font-mono text-sm lg:flex">
<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">
<a
className="pointer-events-none flex place-items-center gap-2 p-8 lg:pointer-events-auto lg:p-0"
href="https://vercel.com?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
By{" "}
<Image
src="/vercel.svg"
alt="Vercel Logo"
className="dark:invert w-auto h-auto"
width={100}
height={24}
priority
/>
</a>
</div>
</div>
<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]">
<Image
className="relative dark:drop-shadow-[0_0_0.3rem_#ffffff70] dark:invert"
src="/next.svg"
alt="Next.js Logo"
width={180}
height={37}
priority
/>
</div>
<Flags />
<div className="mb-32 grid text-center lg:mb-0 lg:w-full lg:max-w-5xl lg:grid-cols-4 lg:text-left">
<a
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
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"
target="_blank"
rel="noopener noreferrer"
>
<h2 className="mb-3 text-2xl font-semibold">
Docs{" "}
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
->
</span>
</h2>
<p className="m-0 max-w-[30ch] text-sm opacity-50">
Find in-depth information about Next.js features and API.
</p>
</a>
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
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"
target="_blank"
rel="noopener noreferrer"
>
<h2 className="mb-3 text-2xl font-semibold">
Learn{" "}
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
->
</span>
</h2>
<p className="m-0 max-w-[30ch] text-sm opacity-50">
Learn about Next.js in an interactive course with quizzes!
</p>
</a>
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
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"
target="_blank"
rel="noopener noreferrer"
>
<h2 className="mb-3 text-2xl font-semibold">
Templates{" "}
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
->
</span>
</h2>
<p className="m-0 max-w-[30ch] text-sm opacity-50">
Explore starter templates for Next.js.
</p>
</a>
<a
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
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"
target="_blank"
rel="noopener noreferrer"
>
<h2 className="mb-3 text-2xl font-semibold">
Deploy{" "}
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
->
</span>
</h2>
<p className="m-0 max-w-[30ch] text-balance text-sm opacity-50">
Instantly deploy your Next.js site to a shareable URL with Vercel.
</p>
</a>
</div>
</main>
);
}
```
--------------------------------------------------------------------------------
/packages/node-sdk/test/flusher.test.ts:
--------------------------------------------------------------------------------
```typescript
import { constants } from "os";
import {
afterEach,
beforeEach,
describe,
expect,
it,
MockInstance,
vi,
} from "vitest";
import { subscribe } from "../src/flusher";
describe("flusher", () => {
const mockExit = vi
.spyOn(process, "exit")
.mockImplementation((() => undefined) as any);
const mockConsoleError = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
const mockProcessOn = vi
.spyOn(process, "on")
.mockImplementation((_, __) => process);
const mockProcessPrependListener = (
vi.spyOn(process, "prependListener") as unknown as MockInstance<
[event: NodeJS.Signals, listener: NodeJS.SignalsListener],
NodeJS.Process
>
).mockImplementation((_, __) => process);
const mockListenerCount = vi
.spyOn(process, "listenerCount")
.mockReturnValue(0);
function timedCallback(ms: number) {
return vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(resolve, ms);
}),
);
}
function getHandler(eventName: string, prepended = false) {
return prepended
? mockProcessPrependListener.mock.calls.filter(
([evt]) => evt === eventName,
)[0][1]
: mockProcessOn.mock.calls.filter(([evt]) => evt === eventName)[0][1];
}
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.resetAllMocks();
});
describe("signal handling", () => {
const signals = ["SIGINT", "SIGTERM", "SIGHUP", "SIGBREAK"] as const;
describe.each(signals)("signal %s", (signal) => {
it("should handle signal with no existing listeners", async () => {
mockListenerCount.mockReturnValue(0);
const callback = vi.fn().mockResolvedValue(undefined);
subscribe(callback);
expect(mockProcessOn).toHaveBeenCalledWith(
signal,
expect.any(Function),
);
getHandler(signal)(signal);
await vi.runAllTimersAsync();
expect(callback).toHaveBeenCalledTimes(1);
expect(mockExit).toHaveBeenCalledWith(0x80 + constants.signals[signal]);
});
it("should prepend handler when listeners exist", async () => {
mockListenerCount.mockReturnValue(1);
const callback = vi.fn().mockResolvedValue(undefined);
subscribe(callback);
expect(mockProcessPrependListener).toHaveBeenCalledWith(
signal,
expect.any(Function),
);
getHandler(signal, true)(signal);
expect(callback).toHaveBeenCalledTimes(1);
expect(mockExit).not.toHaveBeenCalled();
});
});
});
describe("beforeExit handling", () => {
it("should call callback on beforeExit", async () => {
const callback = vi.fn().mockResolvedValue(undefined);
subscribe(callback);
getHandler("beforeExit")();
expect(callback).toHaveBeenCalledTimes(1);
});
it("should not call callback multiple times", async () => {
const callback = vi.fn().mockResolvedValue(undefined);
subscribe(callback);
getHandler("beforeExit")();
getHandler("beforeExit")();
expect(callback).toHaveBeenCalledTimes(1);
});
});
describe("timeout handling", () => {
it("should handle timeout when callback takes too long", async () => {
subscribe(timedCallback(2000), 1000);
getHandler("beforeExit")();
await vi.advanceTimersByTimeAsync(1000);
expect(mockConsoleError).toHaveBeenCalledWith(
"[Reflag SDK] Timeout while flushing events on process exit.",
);
});
it("should not timeout when callback completes in time", async () => {
subscribe(timedCallback(500), 1000);
getHandler("beforeExit")();
await vi.advanceTimersByTimeAsync(500);
expect(mockConsoleError).not.toHaveBeenCalled();
});
});
describe("exit state handling", () => {
it("should log error if exit occurs before flushing starts", () => {
subscribe(timedCallback(0));
getHandler("exit")();
expect(mockConsoleError).toHaveBeenCalledWith(
"[Reflag SDK] Failed to finalize the flushing of events on process exit.",
);
});
it("should log error if exit occurs before flushing completes", async () => {
subscribe(timedCallback(2000));
getHandler("beforeExit")();
await vi.advanceTimersByTimeAsync(1000);
getHandler("exit")();
expect(mockConsoleError).toHaveBeenCalledWith(
"[Reflag SDK] Failed to finalize the flushing of events on process exit.",
);
});
it("should not log error if flushing completes before exit", async () => {
subscribe(timedCallback(500));
getHandler("beforeExit")();
await vi.advanceTimersByTimeAsync(500);
getHandler("exit")();
expect(mockConsoleError).not.toHaveBeenCalled();
});
it("should handle callback errors gracefully", async () => {
subscribe(vi.fn().mockRejectedValue(new Error("Test error")));
getHandler("beforeExit")();
await vi.runAllTimersAsync();
expect(mockConsoleError).toHaveBeenCalledWith(
"[Reflag SDK] An error occurred while flushing events on process exit.",
expect.any(Error),
);
});
});
it("should run the callback only once", async () => {
const callback = vi.fn().mockResolvedValue(undefined);
subscribe(callback);
getHandler("SIGINT")("SIGINT");
getHandler("beforeExit")();
await vi.runAllTimersAsync();
expect(callback).toHaveBeenCalledTimes(1);
});
});
```
--------------------------------------------------------------------------------
/packages/cli/stores/config.ts:
--------------------------------------------------------------------------------
```typescript
import { Ajv, ValidateFunction } from "ajv";
import {
assign as assignJSON,
parse as parseJSON,
stringify as stringifyJSON,
} from "comment-json";
import equal from "fast-deep-equal";
import { findUp } from "find-up";
import { readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import {
CONFIG_FILE_NAME,
DEFAULT_API_URL,
DEFAULT_BASE_URL,
DEFAULT_TYPES_OUTPUT,
MODULE_ROOT,
SCHEMA_URL,
} from "../utils/constants.js";
import { ConfigValidationError, handleError } from "../utils/errors.js";
import { stripTrailingSlash } from "../utils/urls.js";
import { current as currentVersion } from "../utils/version.js";
export const typeFormats = ["react", "node"] as const;
export type TypeFormat = (typeof typeFormats)[number];
export type TypesOutput = {
path: string;
format: TypeFormat;
};
type Config = {
$schema: string;
baseUrl: string;
apiUrl: string;
appId: string | undefined;
typesOutput: TypesOutput[];
};
const defaultConfig: Config = {
$schema: SCHEMA_URL,
baseUrl: DEFAULT_BASE_URL,
apiUrl: DEFAULT_API_URL,
appId: undefined,
typesOutput: [{ path: DEFAULT_TYPES_OUTPUT, format: "react" }],
};
// Helper to normalize typesOutput to array format
export function normalizeTypesOutput(
output?: string | TypesOutput[],
): TypesOutput[] | undefined {
if (!output) return undefined;
if (typeof output === "string") {
return [{ path: output, format: "react" }];
}
return output;
}
class ConfigStore {
protected config: Config = { ...defaultConfig };
protected configPath: string | undefined;
protected projectPath: string | undefined;
protected clientVersion: string | undefined;
protected validateConfig: ValidateFunction | undefined;
async initialize() {
await this.createValidator();
await this.loadConfigFile();
}
protected async createValidator() {
try {
// Using current config store file, resolve the schema.json path
const schemaPath = join(MODULE_ROOT, "schema.json");
const content = await readFile(schemaPath, "utf-8");
const parsed = parseJSON(content) as unknown as Config;
const ajv = new Ajv();
this.validateConfig = ajv.compile(parsed);
} catch {
handleError(new Error("Failed to load the config schema"), "Config");
}
}
protected async loadConfigFile() {
if (!this.validateConfig) {
handleError(new Error("Failed to load the config schema"), "Config");
}
// Load the client version from the module's package.json metadata
try {
const { version } = await currentVersion();
this.clientVersion = version;
} catch {
// Should not be the case, but ignore if no package.json is found
}
try {
const projectMetadataPath = await findUp("package.json");
this.configPath = await findUp(CONFIG_FILE_NAME);
this.projectPath = dirname(
this.configPath ?? projectMetadataPath ?? process.cwd(),
);
if (!this.configPath) return;
const content = await readFile(this.configPath, "utf-8");
const parsed = parseJSON(content) as unknown as Partial<Config>;
// Normalize values
if (parsed.baseUrl)
parsed.baseUrl = stripTrailingSlash(parsed.baseUrl.trim());
if (parsed.apiUrl)
parsed.apiUrl = stripTrailingSlash(parsed.apiUrl.trim());
if (parsed.typesOutput?.length)
parsed.typesOutput = normalizeTypesOutput(parsed.typesOutput);
if (!this.validateConfig!(parsed)) {
handleError(
new ConfigValidationError(this.validateConfig!.errors),
"Config",
);
}
this.config = assignJSON(this.config, parsed);
} catch {
// No config file found
}
}
/**
* Create a new config file with initial values.
* @param overwrite If true, overwrites existing config file. Defaults to false
*/
async saveConfigFile(overwrite = false) {
const configWithoutDefaults: Partial<Config> = assignJSON({}, this.config);
// Only include non-default values and $schema
for (const untypedKey in configWithoutDefaults) {
const key = untypedKey as keyof Config;
if (
!["$schema"].includes(key) &&
equal(configWithoutDefaults[key], defaultConfig[key])
) {
delete configWithoutDefaults[key];
}
}
const configJSON = stringifyJSON(configWithoutDefaults, null, 2);
if (this.configPath && !overwrite) {
throw new Error("Config file already exists");
}
if (this.configPath) {
await writeFile(this.configPath, configJSON);
} else {
// Write to the project path
const packageJSONPath = await findUp("package.json");
this.projectPath = dirname(packageJSONPath ?? process.cwd());
this.configPath = join(this.projectPath, CONFIG_FILE_NAME);
await writeFile(this.configPath, configJSON);
}
}
getConfig(): Config;
getConfig<K extends keyof Config>(key: K): Config[K];
getConfig<K extends keyof Config>(key?: K) {
return key ? this.config?.[key] : this.config;
}
getConfigPath() {
return this.configPath;
}
getClientVersion() {
return this.clientVersion;
}
getProjectPath() {
return this.projectPath ?? process.cwd();
}
setConfig(newConfig: Partial<Config>) {
// Update the config with new values skipping undefined values
for (const untypedKey in newConfig) {
const key = untypedKey as keyof Config;
if (newConfig[key] === undefined) continue;
(this.config as any)[key] = newConfig[key];
}
}
}
export const configStore = new ConfigStore();
```
--------------------------------------------------------------------------------
/packages/openfeature-browser-provider/src/index.ts:
--------------------------------------------------------------------------------
```typescript
import {
ErrorCode,
EvaluationContext,
JsonValue,
OpenFeatureEventEmitter,
Provider,
ProviderMetadata,
ProviderStatus,
ResolutionDetails,
StandardResolutionReasons,
TrackingEventDetails,
} from "@openfeature/web-sdk";
import { Flag, InitOptions, ReflagClient } from "@reflag/browser-sdk";
export type ContextTranslationFn = (
context?: EvaluationContext,
) => Record<string, any>;
export function defaultContextTranslator(
context?: EvaluationContext,
): Record<string, any> {
if (!context) return {};
return {
user: {
id: context.targetingKey ?? context["userId"]?.toString(),
email: context["email"]?.toString(),
name: context["name"]?.toString(),
avatar: context["avatar"]?.toString(),
country: context["country"]?.toString(),
},
company: {
id: context["companyId"]?.toString(),
name: context["companyName"]?.toString(),
plan: context["companyPlan"]?.toString(),
avatar: context["companyAvatar"]?.toString(),
},
};
}
export class ReflagBrowserSDKProvider implements Provider {
readonly metadata: ProviderMetadata = {
name: "reflag-browser-provider",
};
private _client?: ReflagClient;
private readonly _clientOptions: InitOptions;
private readonly _contextTranslator: ContextTranslationFn;
public events = new OpenFeatureEventEmitter();
private _status: ProviderStatus = ProviderStatus.NOT_READY;
set status(status: ProviderStatus) {
this._status = status;
}
get status() {
return this._status;
}
get client() {
return this._client;
}
constructor({
contextTranslator,
...opts
}: InitOptions & { contextTranslator?: ContextTranslationFn }) {
this._clientOptions = opts;
this._contextTranslator = contextTranslator || defaultContextTranslator;
}
async initialize(context?: EvaluationContext): Promise<void> {
const client = new ReflagClient({
...this._clientOptions,
...this._contextTranslator(context),
});
try {
await client.initialize();
this.status = ProviderStatus.READY;
this._client = client;
} catch {
this.status = ProviderStatus.ERROR;
}
}
onClose(): Promise<void> {
if (this._client) {
return this._client?.stop();
}
return Promise.resolve();
}
async onContextChange(
_oldContext: EvaluationContext,
newContext: EvaluationContext,
): Promise<void> {
await this.initialize(newContext);
}
private resolveFlag<T extends JsonValue>(
flagKey: string,
defaultValue: T,
resolveFn: (feature: Flag) => ResolutionDetails<T>,
): ResolutionDetails<T> {
if (!this._client) {
return {
value: defaultValue,
reason: StandardResolutionReasons.DEFAULT,
errorCode: ErrorCode.PROVIDER_NOT_READY,
errorMessage: "Reflag client not initialized",
} satisfies ResolutionDetails<T>;
}
const features = this._client.getFlags();
if (flagKey in features) {
return resolveFn(this._client.getFlag(flagKey));
}
return {
value: defaultValue,
reason: StandardResolutionReasons.DEFAULT,
errorCode: ErrorCode.FLAG_NOT_FOUND,
errorMessage: `Flag ${flagKey} not found`,
};
}
resolveBooleanEvaluation(flagKey: string, defaultValue: boolean) {
return this.resolveFlag(flagKey, defaultValue, (feature) => {
return {
value: feature.isEnabled,
variant: feature.config.key,
reason: StandardResolutionReasons.TARGETING_MATCH,
};
});
}
resolveNumberEvaluation(_flagKey: string, defaultValue: number) {
return {
value: defaultValue,
reason: StandardResolutionReasons.ERROR,
errorCode: ErrorCode.GENERAL,
errorMessage:
"Reflag doesn't support this method. Use `resolveObjectEvaluation` instead.",
};
}
resolveStringEvaluation(
flagKey: string,
defaultValue: string,
): ResolutionDetails<string> {
return this.resolveFlag(flagKey, defaultValue, (feature) => {
if (!feature.config.key) {
return {
value: defaultValue,
reason: StandardResolutionReasons.DEFAULT,
};
}
return {
value: feature.config.key as string,
variant: feature.config.key,
reason: StandardResolutionReasons.TARGETING_MATCH,
};
});
}
resolveObjectEvaluation<T extends JsonValue>(
flagKey: string,
defaultValue: T,
) {
return this.resolveFlag(flagKey, defaultValue, (feature) => {
const expType = typeof defaultValue;
const payloadType = typeof feature.config.payload;
if (
feature.config.payload === undefined ||
feature.config.payload === null ||
payloadType !== expType
) {
return {
value: defaultValue,
reason: StandardResolutionReasons.ERROR,
variant: feature.config.key,
errorCode: ErrorCode.TYPE_MISMATCH,
errorMessage: `Expected remote config payload of type \`${expType}\` but got \`${payloadType}\`.`,
};
}
return {
value: feature.config.payload,
variant: feature.config.key,
reason: StandardResolutionReasons.TARGETING_MATCH,
};
});
}
track(
trackingEventName: string,
_context?: EvaluationContext,
trackingEventDetails?: TrackingEventDetails,
): void {
if (!this._client) {
this._clientOptions.logger?.error("client not initialized");
}
this._client
?.track(trackingEventName, trackingEventDetails)
.catch((e: any) => {
this._clientOptions.logger?.error("error tracking event", e);
});
}
}
```
--------------------------------------------------------------------------------
/packages/node-sdk/src/utils.ts:
--------------------------------------------------------------------------------
```typescript
import { createHash, Hash } from "crypto";
import { IdType, Logger, LogLevel } from "./types";
/**
* Assert that the given condition is `true`.
*
* @param condition - The condition to check.
* @param message - The error message to throw.
**/
export function ok(condition: boolean, message: string): asserts condition {
if (!condition) {
throw new Error(`validation failed: ${message}`);
}
}
/**
* Assert that the given values is a valid user/company id
**/
export function idOk(id: IdType, entity: string) {
ok(
(typeof id === "string" && id.length > 0) || typeof id === "number",
`${entity} must be a string or number if given`,
);
}
/**
* Check if the given item is an object.
*
* @param item - The item to check.
* @returns `true` if the item is an object, `false` otherwise.
**/
export function isObject(item: any): item is Record<string, any> {
return (item && typeof item === "object" && !Array.isArray(item)) || false;
}
export type LogFn = (message: string, ...args: any[]) => void;
export function decorate(prefix: string, fn: LogFn): LogFn {
return (message: string, ...args: any[]) => {
fn(`${prefix} ${message}`, ...args);
};
}
export function applyLogLevel(logger: Logger, logLevel: LogLevel) {
switch (logLevel?.toLocaleUpperCase()) {
case "DEBUG":
return {
debug: decorate("[debug]", logger.debug),
info: decorate("[info]", logger.info),
warn: decorate("[warn]", logger.warn),
error: decorate("[error]", logger.error),
};
case "INFO":
return {
debug: () => void 0,
info: decorate("[info]", logger.info),
warn: decorate("[warn]", logger.warn),
error: decorate("[error]", logger.error),
};
case "WARN":
return {
debug: () => void 0,
info: () => void 0,
warn: decorate("[warn]", logger.warn),
error: decorate("[error]", logger.error),
};
case "ERROR":
return {
debug: () => void 0,
info: () => void 0,
warn: () => void 0,
error: decorate("[error]", logger.error),
};
default:
throw new Error(`invalid log level: ${logLevel}`);
}
}
/**
* Decorate the messages of a given logger with the given prefix.
*
* @param prefix - The prefix to add to log messages.
* @param logger - The logger to decorate.
* @returns The decorated logger.
**/
export function decorateLogger(prefix: string, logger: Logger): Logger {
ok(typeof prefix === "string", "prefix must be a string");
ok(typeof logger === "object", "logger must be an object");
return {
debug: decorate(prefix, logger.debug),
info: decorate(prefix, logger.info),
warn: decorate(prefix, logger.warn),
error: decorate(prefix, logger.error),
};
}
/** Merge two objects, skipping `undefined` values.
*
* @param target - The target object.
* @param source - The source object.
* @returns The merged object.
**/
export function mergeSkipUndefined<T extends object, U extends object>(
target: T,
source: U,
): T & U {
const newTarget = { ...target };
for (const key in source) {
if (source[key] === undefined) {
continue;
}
(newTarget as any)[key] = source[key];
}
return newTarget as T & U;
}
function updateSha1Hash(hash: Hash, value: any) {
if (value === null) {
hash.update("null");
} else {
switch (typeof value) {
case "object":
if (Array.isArray(value)) {
for (const item of value) {
updateSha1Hash(hash, item);
}
} else {
for (const key of Object.keys(value).sort()) {
hash.update(key);
updateSha1Hash(hash, value[key]);
}
}
break;
case "string":
hash.update(value);
break;
case "number":
case "boolean":
case "symbol":
case "bigint":
case "function":
hash.update(value.toString());
break;
case "undefined":
default:
break;
}
}
}
/** Hash an object using SHA1.
*
* @param obj - The object to hash.
*
* @returns The SHA1 hash of the object.
**/
export function hashObject(obj: Record<string, any>): string {
ok(isObject(obj), "obj must be an object");
const hash = createHash("sha1");
updateSha1Hash(hash, obj);
return hash.digest("base64");
}
export function once<T extends () => ReturnType<T>>(
fn: T,
): () => ReturnType<T> {
let called = false;
let returned: ReturnType<T> | undefined;
return function (): ReturnType<T> {
if (called) {
return returned!;
}
returned = fn();
called = true;
return returned;
};
}
export class TimeoutError extends Error {
constructor(timeoutMs: number) {
super(`Operation timed out after ${timeoutMs}ms`);
this.name = "TimeoutError";
}
}
/**
* Wraps a promise with a timeout. If the promise doesn't resolve within the specified
* timeout, it will reject with a timeout error. The original promise will still
* continue to execute but its result will be ignored.
*
* @param promise - The promise to wrap with a timeout
* @param timeoutMs - The timeout in milliseconds
* @returns A promise that resolves with the original promise result or rejects with a timeout error
* @throws {Error} If the timeout is reached before the promise resolves
**/
export function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
): Promise<T> {
ok(timeoutMs > 0, "timeout must be a positive number");
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new TimeoutError(timeoutMs));
}, timeoutMs);
promise
.then((result) => {
resolve(result);
})
.catch((error) => {
reject(error);
})
.finally(() => {
clearTimeout(timeoutId);
});
});
}
```
--------------------------------------------------------------------------------
/packages/cli/commands/flags.ts:
--------------------------------------------------------------------------------
```typescript
import { input } from "@inquirer/prompts";
import chalk from "chalk";
import { Command } from "commander";
import { relative } from "node:path";
import ora, { Ora } from "ora";
import { App, getApp, getOrg } from "../services/bootstrap.js";
import { createFlag, Flag, listFlags } from "../services/flags.js";
import { configStore } from "../stores/config.js";
import {
handleError,
MissingAppIdError,
MissingEnvIdError,
} from "../utils/errors.js";
import {
genFlagKey,
genTypes,
indentLines,
KeyFormatPatterns,
writeTypesToFile,
} from "../utils/gen.js";
import {
appIdOption,
flagKeyOption,
flagNameArgument,
typesFormatOption,
typesOutOption,
} from "../utils/options.js";
import { baseUrlSuffix, featureUrl } from "../utils/urls.js";
type CreateFlagOptions = {
key?: string;
};
export const createFlagAction = async (
name: string | undefined,
{ key }: CreateFlagOptions,
) => {
const { baseUrl, appId } = configStore.getConfig();
let spinner: Ora | undefined;
if (!appId) {
handleError(new MissingAppIdError(), "Flags Create");
}
let app: App;
try {
app = getApp(appId);
} catch (error) {
handleError(error, "Flags Create");
}
const production = app.environments.find((e) => e.isProduction);
try {
const org = getOrg();
console.log(
`Creating flag for app ${chalk.cyan(app.name)}${baseUrlSuffix(baseUrl)}.`,
);
if (!name) {
name = await input({
message: "New flag name:",
validate: (text) => text.length > 0 || "Name is required.",
});
}
if (!key) {
const keyFormat = org.featureKeyFormat;
const keyValidator = KeyFormatPatterns[keyFormat];
key = await input({
message: "New flag key:",
default: genFlagKey(name, keyFormat),
validate: (str) => keyValidator.regex.test(str) || keyValidator.message,
});
}
spinner = ora(`Creating flag...`).start();
const flag = await createFlag(appId, { name, key });
spinner.succeed(
`Created flag ${chalk.cyan(flag.name)} with key ${chalk.cyan(flag.key)}:`,
);
if (production) {
console.log(
indentLines(chalk.magenta(featureUrl(baseUrl, production, flag))),
);
}
} catch (error) {
spinner?.fail("Flag creation failed.");
handleError(error, "Flags Create");
}
};
export const listFlagsAction = async () => {
const { baseUrl, appId } = configStore.getConfig();
let spinner: Ora | undefined;
if (!appId) {
handleError(new MissingAppIdError(), "Flags Create");
}
try {
const app = getApp(appId);
const production = app.environments.find((e) => e.isProduction);
if (!production) {
handleError(new MissingEnvIdError(), "Flags Types");
}
spinner = ora(
`Loading flags of app ${chalk.cyan(app.name)}${baseUrlSuffix(baseUrl)}...`,
).start();
const flagsResponse = await listFlags(appId, {
envId: production.id,
});
spinner.succeed(
`Loaded flags of app ${chalk.cyan(app.name)}${baseUrlSuffix(baseUrl)}.`,
);
console.table(
flagsResponse.data.map(({ key, name, stage }) => ({
name,
key,
stage: stage?.name,
})),
);
} catch (error) {
spinner?.fail("Loading flags failed.");
handleError(error, "Flags List");
}
};
export const generateTypesAction = async () => {
const { baseUrl, appId } = configStore.getConfig();
const typesOutput = configStore.getConfig("typesOutput");
let spinner: Ora | undefined;
let flags: Flag[] = [];
if (!appId) {
handleError(new MissingAppIdError(), "Flags Types");
}
let app: App;
try {
app = getApp(appId);
} catch (error) {
handleError(error, "Flags Types");
}
const production = app.environments.find((e) => e.isProduction);
if (!production) {
handleError(new MissingEnvIdError(), "Flags Types");
}
try {
spinner = ora(
`Loading flags of app ${chalk.cyan(app.name)}${baseUrlSuffix(baseUrl)}...`,
).start();
flags = await listFlags(appId, {
envId: production.id,
includeRemoteConfigs: true,
}).then((res) => res.data);
spinner.succeed(
`Loaded flags of app ${chalk.cyan(app.name)}${baseUrlSuffix(baseUrl)}.`,
);
} catch (error) {
spinner?.fail("Loading flags failed.");
handleError(error, "Flags Types");
}
try {
spinner = ora(`Generating flag types...`).start();
const projectPath = configStore.getProjectPath();
// Generate types for each output configuration
for (const output of typesOutput) {
const types = genTypes(flags, output.format);
const outPath = await writeTypesToFile(types, output.path, projectPath);
spinner.succeed(
`Generated ${output.format} types in ${chalk.cyan(relative(projectPath, outPath))}.`,
);
}
} catch (error) {
spinner?.fail("Type generation failed.");
handleError(error, "Flags Types");
}
};
export function registerFlagCommands(cli: Command) {
const flagsCommand = new Command("flags").description("Manage flags.");
flagsCommand
.command("create")
.description("Create a new flag.")
.addOption(appIdOption)
.addOption(flagKeyOption)
.addArgument(flagNameArgument)
.action(createFlagAction);
flagsCommand
.command("list")
.alias("ls")
.description("List all flags.")
.addOption(appIdOption)
.action(listFlagsAction);
flagsCommand
.command("types")
.description("Generate flag types.")
.addOption(appIdOption)
.addOption(typesOutOption)
.addOption(typesFormatOption)
.action(generateTypesAction);
// Update the config with the cli override values
flagsCommand.hook("preAction", (_, command) => {
const { appId, out, format } = command.opts();
configStore.setConfig({
appId,
typesOutput: out ? [{ path: out, format: format || "react" }] : undefined,
});
});
cli.addCommand(flagsCommand);
}
```
--------------------------------------------------------------------------------
/packages/cli/utils/json.ts:
--------------------------------------------------------------------------------
```typescript
export type JSONPrimitive =
| number
| string
| boolean
| null
| JSONPrimitive[]
| { [key: string]: JSONPrimitive };
export type PrimitiveAST = { kind: "primitive"; type: string };
export type ArrayAST = { kind: "array"; elementType: TypeAST };
export type ObjectAST = {
kind: "object";
properties: { key: string; type: TypeAST; optional: boolean }[];
};
export type UnionAST = { kind: "union"; types: TypeAST[] };
// Type AST to represent TypeScript types
export type TypeAST = PrimitiveAST | ArrayAST | ObjectAST | UnionAST;
// Convert JSON value to TypeAST
export function toTypeAST(value: JSONPrimitive, path: string[] = []): TypeAST {
if (value === null) return { kind: "primitive", type: "null" };
if (Array.isArray(value)) {
if (value.length === 0) {
return {
kind: "array",
elementType: { kind: "primitive", type: "any" },
};
}
// Process all elements in the array instead of just the first one
const elementTypes = value.map((item, index) =>
toTypeAST(item, [...path, index.toString()]),
);
return {
kind: "array",
elementType: mergeTypeASTs(elementTypes),
};
}
if (typeof value === "object") {
return {
kind: "object",
properties: Object.entries(value).map(([key, val]) => ({
key,
type: toTypeAST(val, [...path, key]),
optional: false,
})),
};
}
return { kind: "primitive", type: typeof value };
}
// Merge multiple TypeASTs into one
export function mergeTypeASTs(types: TypeAST[]): TypeAST {
if (types.length === 0) return { kind: "primitive", type: "any" };
if (types.length === 1) return types[0];
// Group ASTs by kind
const byKind = {
union: types.filter((t) => t.kind === "union"),
primitive: types.filter((t) => t.kind === "primitive"),
array: types.filter((t) => t.kind === "array"),
object: types.filter((t) => t.kind === "object"),
};
// Create a union for mixed kinds
const hasMixedKinds =
byKind.union.length > 0 || // If we have any unions, treat it as mixed kinds
(byKind.primitive.length > 0 &&
(byKind.array.length > 0 || byKind.object.length > 0)) ||
(byKind.array.length > 0 && byKind.object.length > 0);
if (hasMixedKinds) {
// If there are existing unions, flatten them into the current union
if (byKind.union.length > 0) {
// Flatten existing unions and collect types by category
const flattenedTypes: TypeAST[] = [];
const objectsToMerge: ObjectAST[] = [...byKind.object];
const arraysToMerge: ArrayAST[] = [...byKind.array];
// Add primitives directly
flattenedTypes.push(...byKind.primitive);
// Process union types
for (const unionType of byKind.union) {
for (const type of unionType.types) {
if (type.kind === "object") {
objectsToMerge.push(type);
} else if (type.kind === "array") {
arraysToMerge.push(type);
} else {
flattenedTypes.push(type);
}
}
}
// Merge objects and arrays if they exist
if (objectsToMerge.length > 0) {
flattenedTypes.push(mergeTypeASTs(objectsToMerge));
}
if (arraysToMerge.length > 0) {
flattenedTypes.push(mergeTypeASTs(arraysToMerge));
}
return { kind: "union", types: flattenedTypes };
}
return { kind: "union", types };
}
// Handle primitives
if (byKind.primitive.length === types.length) {
const uniqueTypes = [...new Set(byKind.primitive.map((p) => p.type))];
return uniqueTypes.length === 1
? { kind: "primitive", type: uniqueTypes[0] }
: {
kind: "union",
types: uniqueTypes.map((type) => ({ kind: "primitive", type })),
};
}
// Merge arrays
if (byKind.array.length === types.length) {
return {
kind: "array",
elementType: mergeTypeASTs(byKind.array.map((a) => a.elementType)),
};
}
// Merge objects
if (byKind.object.length === types.length) {
// Get all unique property keys
const allKeys = [
...new Set(
byKind.object.flatMap((obj) => obj.properties.map((p) => p.key)),
),
];
// Merge properties with same keys
const mergedProperties = allKeys.map((key) => {
const props = byKind.object
.map((obj) => obj.properties.find((p) => p.key === key))
.filter((obj) => !!obj);
return {
key,
type: mergeTypeASTs(props.map((p) => p.type)),
optional: byKind.object.some(
(obj) => !obj.properties.some((p) => p.key === key),
),
};
});
return { kind: "object", properties: mergedProperties };
}
// Fallback
return { kind: "primitive", type: "any" };
}
// Stringify TypeAST to TypeScript type declaration
export function stringifyTypeAST(ast: TypeAST, nestLevel = 0): string {
const indent = " ".repeat(nestLevel * 2);
const nextIndent = " ".repeat((nestLevel + 1) * 2);
switch (ast.kind) {
case "primitive":
return ast.type;
case "array":
return `(${stringifyTypeAST(ast.elementType, nestLevel)})[]`;
case "object":
if (ast.properties.length === 0) return "{}";
return `{\n${ast.properties
.map(({ key, optional, type }) => {
return `${nextIndent}${quoteKey(key)}${optional ? "?" : ""}: ${stringifyTypeAST(
type,
nestLevel + 1,
)}`;
})
.join(",\n")}\n${indent}}`;
case "union":
if (ast.types.length === 0) return "any";
if (ast.types.length === 1)
return stringifyTypeAST(ast.types[0], nestLevel);
return ast.types
.map((type) => stringifyTypeAST(type, nestLevel))
.join(" | ");
}
}
export function quoteKey(key: string): string {
return /[^a-zA-Z0-9_]/.test(key) || /^[0-9]/.test(key) ? `"${key}"` : key;
}
// Convert JSON array to TypeScript type
export function JSONToType(json: JSONPrimitive[]): string | null {
if (!json.length) return null;
return stringifyTypeAST(mergeTypeASTs(json.map((item) => toTypeAST(item))));
}
```
--------------------------------------------------------------------------------
/packages/node-sdk/test/batch-buffer.test.ts:
--------------------------------------------------------------------------------
```typescript
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import BatchBuffer from "../src/batch-buffer";
import { BATCH_INTERVAL_MS, BATCH_MAX_SIZE } from "../src/config";
import { Logger } from "../src/types";
describe("BatchBuffer", () => {
const mockFlushHandler = vi.fn();
const mockLogger: Logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("constructor", () => {
it("should throw an error if options are invalid", () => {
expect(() => new BatchBuffer(null as any)).toThrow(
"options must be an object",
);
expect(() => new BatchBuffer("bad" as any)).toThrow(
"options must be an object",
);
expect(
() => new BatchBuffer({ flushHandler: null as any } as any),
).toThrow("flushHandler must be a function");
expect(
() => new BatchBuffer({ flushHandler: "not a function" } as any),
).toThrow("flushHandler must be a function");
expect(
() =>
new BatchBuffer({
flushHandler: mockFlushHandler,
logger: "string",
} as any),
).toThrow("logger must be an object");
expect(
() =>
new BatchBuffer({
flushHandler: mockFlushHandler,
maxSize: -1,
} as any),
).toThrow("maxSize must be greater than 0");
});
it("should initialize with specified values", () => {
const buffer = new BatchBuffer({
flushHandler: mockFlushHandler,
maxSize: 22,
intervalMs: 33,
});
expect(buffer).toEqual({
buffer: [],
flushHandler: mockFlushHandler,
timer: null,
intervalMs: 33,
logger: undefined,
maxSize: 22,
});
});
it("should initialize with default values if not provided", () => {
const buffer = new BatchBuffer({ flushHandler: mockFlushHandler });
expect(buffer).toEqual({
buffer: [],
flushHandler: mockFlushHandler,
intervalMs: BATCH_INTERVAL_MS,
maxSize: BATCH_MAX_SIZE,
timer: null,
});
});
});
describe("add", () => {
it("should add item to the buffer and flush immediately if maxSize is reached", async () => {
const buffer = new BatchBuffer({
flushHandler: mockFlushHandler,
maxSize: 1,
});
await buffer.add("item1");
expect(mockFlushHandler).toHaveBeenCalledWith(["item1"]);
expect(mockFlushHandler).toHaveBeenCalledTimes(1);
});
it("should set a flush timer if buffer does not reach maxSize", async () => {
vi.useFakeTimers();
const buffer = new BatchBuffer({
flushHandler: mockFlushHandler,
maxSize: 2,
intervalMs: 1000,
});
await buffer.add("item1");
expect(mockFlushHandler).not.toHaveBeenCalled();
vi.advanceTimersByTime(1000);
expect(mockFlushHandler).toHaveBeenCalledWith(["item1"]);
expect(mockFlushHandler).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
});
describe("flush", () => {
it("should not do anything if there are no items to flush", async () => {
const buffer = new BatchBuffer({
flushHandler: mockFlushHandler,
logger: mockLogger,
});
await buffer.flush();
expect(mockFlushHandler).not.toHaveBeenCalled();
expect(mockLogger.debug).toHaveBeenCalledWith(
"buffer is empty. nothing to flush",
);
});
it("calling flush simultaneously should only flush data once", async () => {
let itemsFlushed = 0;
const buffer = new BatchBuffer({
flushHandler: async (items) => {
itemsFlushed += items.length;
await new Promise((resolve) => setTimeout(resolve, 100));
mockFlushHandler();
},
logger: mockLogger,
});
await buffer.add("item1");
await Promise.all([buffer.flush(), buffer.flush()]);
expect(itemsFlushed).toBe(1);
});
it("should flush buffer", async () => {
const buffer = new BatchBuffer({
flushHandler: mockFlushHandler,
logger: mockLogger,
});
await buffer.add("item1");
await buffer.flush();
expect(mockFlushHandler).toHaveBeenCalledWith(["item1"]);
await buffer.flush();
expect(mockFlushHandler).toHaveBeenCalledTimes(1);
});
it("should log correctly during flush", async () => {
const buffer = new BatchBuffer({
flushHandler: mockFlushHandler,
logger: mockLogger,
});
await buffer.add("item1");
await buffer.flush();
expect(mockLogger.info).toHaveBeenCalledWith("flushed buffered items", {
count: 1,
});
});
});
describe("timer logic", () => {
beforeAll(() => {
vi.useFakeTimers();
});
afterAll(() => {
vi.useRealTimers();
});
beforeEach(() => {
vi.clearAllTimers();
mockFlushHandler.mockReset();
});
it("should start the normal timer when adding first item", async () => {
const buffer = new BatchBuffer({
flushHandler: mockFlushHandler,
logger: mockLogger,
intervalMs: 100,
});
expect(buffer["timer"]).toBeNull();
await buffer.add("item1");
expect(buffer["timer"]).toBeDefined();
await vi.advanceTimersByTimeAsync(100);
expect(mockFlushHandler).toHaveBeenCalledTimes(1);
expect(buffer["timer"]).toBeNull();
expect(mockLogger.info).toHaveBeenCalledWith("flushed buffered items", {
count: 1,
});
});
it("should stop the normal timer if flushed manually", async () => {
const buffer = new BatchBuffer({
flushHandler: mockFlushHandler,
logger: mockLogger,
intervalMs: 100,
maxSize: 2,
});
await buffer.add("item1");
await buffer.add("item2");
expect(buffer["timer"]).toBeNull();
expect(mockLogger.info).toHaveBeenCalledWith("flushed buffered items", {
count: 2,
});
});
});
});
```
--------------------------------------------------------------------------------
/packages/openfeature-node-provider/src/index.ts:
--------------------------------------------------------------------------------
```typescript
import {
ErrorCode,
EvaluationContext,
JsonValue,
OpenFeatureEventEmitter,
Paradigm,
Provider,
ResolutionDetails,
ServerProviderStatus,
StandardResolutionReasons,
TrackingEventDetails,
} from "@openfeature/server-sdk";
import {
ClientOptions,
Context as ReflagContext,
ReflagClient,
} from "@reflag/node-sdk";
type ProviderOptions = ClientOptions & {
contextTranslator?: (context: EvaluationContext) => ReflagContext;
};
export const defaultContextTranslator = (
context: EvaluationContext,
): ReflagContext => {
const user = {
id: context.targetingKey ?? context["userId"]?.toString(),
name: context["name"]?.toString(),
email: context["email"]?.toString(),
avatar: context["avatar"]?.toString(),
country: context["country"]?.toString(),
};
const company = {
id: context["companyId"]?.toString(),
name: context["companyName"]?.toString(),
avatar: context["companyAvatar"]?.toString(),
plan: context["companyPlan"]?.toString(),
};
return {
user,
company,
};
};
export class ReflagNodeProvider implements Provider {
public readonly events = new OpenFeatureEventEmitter();
private _client: ReflagClient;
private contextTranslator: (context: EvaluationContext) => ReflagContext;
public runsOn: Paradigm = "server";
public status: ServerProviderStatus = ServerProviderStatus.NOT_READY;
public metadata = {
name: "reflag-node",
};
get client() {
return this._client;
}
constructor({ contextTranslator, ...opts }: ProviderOptions) {
this._client = new ReflagClient(opts);
this.contextTranslator = contextTranslator ?? defaultContextTranslator;
}
public async initialize(): Promise<void> {
await this._client.initialize();
this.status = ServerProviderStatus.READY;
}
private resolveFlag<T extends JsonValue>(
flagKey: string,
defaultValue: T,
context: ReflagContext,
resolveFn: (
feature: ReturnType<typeof this._client.getFlag>,
) => Promise<ResolutionDetails<T>>,
): Promise<ResolutionDetails<T>> {
if (this.status !== ServerProviderStatus.READY) {
return Promise.resolve({
value: defaultValue,
reason: StandardResolutionReasons.ERROR,
errorCode: ErrorCode.PROVIDER_NOT_READY,
errorMessage: "Reflag client not initialized",
});
}
if (!context.user?.id) {
return Promise.resolve({
value: defaultValue,
reason: StandardResolutionReasons.ERROR,
errorCode: ErrorCode.INVALID_CONTEXT,
errorMessage: "At least a user ID is required",
});
}
const featureDefs = this._client.getFlagDefinitions();
if (featureDefs.some(({ key }) => key === flagKey)) {
return resolveFn(this._client.getFlag(context, flagKey));
}
return Promise.resolve({
value: defaultValue,
reason: StandardResolutionReasons.ERROR,
errorCode: ErrorCode.FLAG_NOT_FOUND,
errorMessage: `Flag ${flagKey} not found`,
});
}
resolveBooleanEvaluation(
flagKey: string,
defaultValue: boolean,
context: EvaluationContext,
): Promise<ResolutionDetails<boolean>> {
return this.resolveFlag(
flagKey,
defaultValue,
this.contextTranslator(context),
(feature) => {
return Promise.resolve({
value: feature.isEnabled,
variant: feature.config?.key,
reason: StandardResolutionReasons.TARGETING_MATCH,
});
},
);
}
resolveStringEvaluation(
flagKey: string,
defaultValue: string,
context: EvaluationContext,
): Promise<ResolutionDetails<string>> {
return this.resolveFlag(
flagKey,
defaultValue,
this.contextTranslator(context),
(feature) => {
if (!feature.config.key) {
return Promise.resolve({
value: defaultValue,
reason: StandardResolutionReasons.DEFAULT,
});
}
return Promise.resolve({
value: feature.config.key as string,
variant: feature.config.key,
reason: StandardResolutionReasons.TARGETING_MATCH,
});
},
);
}
resolveNumberEvaluation(
_flagKey: string,
defaultValue: number,
): Promise<ResolutionDetails<number>> {
return Promise.resolve({
value: defaultValue,
reason: StandardResolutionReasons.ERROR,
errorCode: ErrorCode.GENERAL,
errorMessage:
"Reflag doesn't support this method. Use `resolveObjectEvaluation` instead.",
});
}
resolveObjectEvaluation<T extends JsonValue>(
flagKey: string,
defaultValue: T,
context: EvaluationContext,
): Promise<ResolutionDetails<T>> {
return this.resolveFlag(
flagKey,
defaultValue,
this.contextTranslator(context),
(feature) => {
const expType = typeof defaultValue;
const payloadType = typeof feature.config.payload;
if (
feature.config.payload === undefined ||
feature.config.payload === null ||
payloadType !== expType
) {
return Promise.resolve({
value: defaultValue,
variant: feature.config.key,
reason: StandardResolutionReasons.ERROR,
errorCode: ErrorCode.TYPE_MISMATCH,
errorMessage: `Expected remote config payload of type \`${expType}\` but got \`${payloadType}\`.`,
});
}
return Promise.resolve({
value: feature.config.payload,
variant: feature.config.key,
reason: StandardResolutionReasons.TARGETING_MATCH,
});
},
);
}
track(
trackingEventName: string,
context?: EvaluationContext,
trackingEventDetails?: TrackingEventDetails,
): void {
const translatedContext = context
? this.contextTranslator(context)
: undefined;
const userId = translatedContext?.user?.id;
if (!userId) {
this._client.logger?.warn("No user ID provided for tracking event");
return;
}
void this._client.track(String(userId), trackingEventName, {
attributes: trackingEventDetails,
companyId: translatedContext?.company?.id?.toString(),
});
}
public async onClose(): Promise<void> {
await this._client.flush();
}
}
```
--------------------------------------------------------------------------------
/packages/browser-sdk/src/ui/Dialog.tsx:
--------------------------------------------------------------------------------
```typescript
import { MiddlewareData, Placement } from "@floating-ui/dom";
import { Fragment, FunctionComponent, h, Ref } from "preact";
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import {
arrow,
autoUpdate,
flip,
offset,
shift,
useFloating,
} from "./packages/floating-ui-preact-dom";
import styles from "./Dialog.css?inline";
import { Position } from "./types";
import { parseUnanchoredPosition } from "./utils";
type CssPosition = Partial<
Record<"top" | "left" | "right" | "bottom", number | string>
>;
export interface OpenDialogOptions {
/**
* Control the placement and behavior of the dialog.
*/
position: Position;
strategy?: "fixed" | "absolute";
isOpen: boolean;
close: () => void;
onDismiss?: () => void;
containerId: string;
showArrow?: boolean;
children?: preact.ComponentChildren;
}
export function useDialog({
onClose,
onOpen,
initialValue = false,
}: {
onClose?: () => void;
onOpen?: () => void;
initialValue?: boolean;
} = {}) {
const [isOpen, setIsOpen] = useState<boolean>(initialValue);
const open = useCallback(() => {
setIsOpen(true);
onOpen?.();
}, [onOpen]);
const close = useCallback(() => {
setIsOpen(false);
onClose?.();
}, [onClose]);
const toggle = useCallback(() => {
if (isOpen) onClose?.();
else onOpen?.();
setIsOpen((prev) => !prev);
}, [isOpen, onClose, onOpen]);
return {
isOpen,
open,
close,
toggle,
};
}
export const Dialog: FunctionComponent<OpenDialogOptions> = ({
position,
isOpen,
close,
onDismiss,
containerId,
strategy,
children,
showArrow = true,
}) => {
const arrowRef = useRef<HTMLDivElement>(null);
const dialogRef = useRef<HTMLDialogElement>(null);
const anchor = position.type === "POPOVER" ? position.anchor : null;
const placement =
position.type === "POPOVER" ? position.placement : undefined;
const {
refs,
floatingStyles,
middlewareData,
placement: actualPlacement,
} = useFloating({
elements: {
reference: anchor,
},
strategy,
transform: false,
placement,
whileElementsMounted: autoUpdate,
middleware: [
flip({
padding: 10,
mainAxis: true,
crossAxis: true,
}),
shift(),
offset(8),
arrow({
element: arrowRef,
}),
],
});
let unanchoredPosition: CssPosition = {};
if (position.type === "DIALOG") {
unanchoredPosition = parseUnanchoredPosition(position);
}
const dismiss = useCallback(() => {
close();
onDismiss?.();
}, [close, onDismiss]);
useEffect(() => {
// Only enable 'quick dismiss' for popovers
if (position.type === "MODAL" || position.type === "DIALOG") return;
const escapeHandler = (e: KeyboardEvent) => {
if (e.key == "Escape") {
dismiss();
}
};
const clickOutsideHandler = (e: MouseEvent) => {
if (
!(e.target instanceof Element) ||
!e.target.closest(`#${containerId}`)
) {
dismiss();
}
};
const observer = new MutationObserver((mutations) => {
if (position.anchor === null) return;
mutations.forEach((mutation) => {
const removedNodes = Array.from(mutation.removedNodes);
const hasBeenRemoved = removedNodes.some((node) => {
return node === position.anchor || node.contains(position.anchor);
});
if (hasBeenRemoved) {
close();
}
});
});
window.addEventListener("mousedown", clickOutsideHandler);
window.addEventListener("keydown", escapeHandler);
observer.observe(document.body, {
subtree: true,
childList: true,
});
return () => {
window.removeEventListener("mousedown", clickOutsideHandler);
window.removeEventListener("keydown", escapeHandler);
observer.disconnect();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- anchor only exists in popover
}, [position.type, close, (position as any).anchor, dismiss, containerId]);
function setDiagRef(node: HTMLDialogElement | null) {
refs.setFloating(node);
dialogRef.current = node;
}
useEffect(() => {
if (!dialogRef.current) return;
if (isOpen && !dialogRef.current.hasAttribute("open")) {
dialogRef.current[position.type === "MODAL" ? "showModal" : "show"]();
}
if (!isOpen && dialogRef.current.hasAttribute("open")) {
dialogRef.current.close();
}
}, [dialogRef, isOpen, position.type]);
const classes = [
"dialog",
position.type === "MODAL"
? "modal"
: position.type === "POPOVER"
? "anchored"
: `unanchored unanchored-${position.placement}`,
actualPlacement,
].join(" ");
return (
<>
<style dangerouslySetInnerHTML={{ __html: styles }} />
<dialog
ref={setDiagRef}
class={classes}
style={anchor ? floatingStyles : unanchoredPosition}
>
{children && <Fragment>{children}</Fragment>}
{anchor && showArrow && (
<DialogArrow
arrowData={middlewareData?.arrow}
arrowRef={arrowRef}
placement={actualPlacement}
/>
)}
</dialog>
</>
);
};
function DialogArrow({
arrowData,
arrowRef,
placement,
}: {
arrowData: MiddlewareData["arrow"];
arrowRef: Ref<HTMLDivElement>;
placement: Placement;
}) {
const { x: arrowX, y: arrowY } = arrowData ?? {};
const staticSide =
{
top: "bottom",
right: "left",
bottom: "top",
left: "right",
}[placement.split("-")[0]] || "bottom";
const arrowStyles = {
left: arrowX != null ? `${arrowX}px` : "",
top: arrowY != null ? `${arrowY}px` : "",
right: "",
bottom: "",
[staticSide]: "-4px",
};
return (
<div
ref={arrowRef}
class={["arrow", placement].join(" ")}
style={arrowStyles}
/>
);
}
export function DialogHeader({
children,
innerRef,
}: {
children: preact.ComponentChildren;
innerRef?: Ref<HTMLElement>;
}) {
return (
<header ref={innerRef} class="dialog-header">
{children}
</header>
);
}
export function DialogContent({
children,
innerRef,
}: {
children: preact.ComponentChildren;
innerRef?: Ref<HTMLDivElement>;
}) {
return (
<div ref={innerRef} class="dialog-content">
{children}
</div>
);
}
```