├── packages ├── dashboard │ ├── src │ │ ├── lib │ │ │ ├── enviroment-store.ts │ │ │ ├── database-store.ts │ │ │ └── countries.ts │ │ ├── app │ │ │ ├── favicon.ico │ │ │ ├── globals.css │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ │ ├── server │ │ │ └── credentials.ts │ │ ├── components │ │ │ ├── input-label.tsx │ │ │ ├── add-flag.tsx │ │ │ ├── empty.tsx │ │ │ ├── rename-flag-modal.tsx │ │ │ ├── flag-dropdown.tsx │ │ │ ├── select-db.tsx │ │ │ ├── add-db-modal.tsx │ │ │ ├── flag-rules.tsx │ │ │ └── flag.tsx │ │ ├── api │ │ │ ├── query-client.ts │ │ │ └── flags.ts │ │ └── hooks │ │ │ └── use-redis.tsx │ ├── .eslintrc.json │ ├── next.config.mjs │ ├── CHANGELOG.md │ ├── postcss.config.mjs │ ├── tailwind.config.ts │ ├── README.md │ ├── .gitignore │ ├── tsconfig.json │ ├── prettier.config.mjs │ ├── package.json │ └── bin.cjs └── sdk │ ├── src │ ├── version.ts │ ├── environment.ts │ ├── index.ts │ ├── types.ts │ ├── flag.ts │ ├── cache.ts │ ├── evaluation.ts │ ├── middleware.ts │ ├── client.ts │ ├── hook.ts │ ├── admin.test.ts │ ├── handler.ts │ ├── rules.ts │ ├── admin.ts │ └── rules.test.ts │ ├── jest.config.js │ ├── tsup.config.js │ ├── CHANGELOG.md │ ├── package.json │ └── tsconfig.json ├── img ├── flag.png ├── simple.png ├── with-cdn.png └── edge-flags │ ├── flag.png │ ├── rule.png │ ├── context.png │ ├── created.png │ ├── simple.png │ ├── with-cdn.png │ ├── countries.png │ └── percentage.png ├── pnpm-workspace.yaml ├── examples ├── middleware-rewrites │ ├── app │ │ ├── page.tsx │ │ ├── blocked │ │ │ └── page.tsx │ │ └── layout.tsx │ ├── public │ │ ├── favicon.ico │ │ ├── vercel.svg │ │ ├── thirteen.svg │ │ └── next.svg │ ├── next.config.mjs │ ├── README.md │ ├── CHANGELOG.md │ ├── .gitignore │ ├── package.json │ ├── tsconfig.json │ └── middleware.ts └── nextjs │ ├── public │ ├── favicon.ico │ └── vercel.svg │ ├── next.config.js │ ├── next-env.d.ts │ ├── CHANGELOG.md │ ├── styles │ ├── globals.css │ └── Home.module.css │ ├── app │ ├── layout.tsx │ ├── api │ │ └── edge-flags │ │ │ └── route.ts │ └── page.tsx │ ├── .gitignore │ ├── README.md │ ├── package.json │ └── tsconfig.json ├── .github ├── dependabot.yml └── workflows │ └── changeset.yml ├── .changeset ├── config.json └── README.md ├── turbo.json ├── .gitignore ├── rome.json ├── cmd └── set-version.js ├── package.json ├── docs ├── percentage.md ├── react.md ├── overview.md ├── environments.md ├── getstarted.md └── rules.md └── README.md /packages/dashboard/src/lib/enviroment-store.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/sdk/src/version.ts: -------------------------------------------------------------------------------- 1 | export const VERSION = "development"; 2 | -------------------------------------------------------------------------------- /img/flag.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/upstash/edge-flags/main/img/flag.png -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "packages/*" 3 | - "examples/*" 4 | -------------------------------------------------------------------------------- /img/simple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/upstash/edge-flags/main/img/simple.png -------------------------------------------------------------------------------- /img/with-cdn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/upstash/edge-flags/main/img/with-cdn.png -------------------------------------------------------------------------------- /packages/dashboard/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /img/edge-flags/flag.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/upstash/edge-flags/main/img/edge-flags/flag.png -------------------------------------------------------------------------------- /img/edge-flags/rule.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/upstash/edge-flags/main/img/edge-flags/rule.png -------------------------------------------------------------------------------- /img/edge-flags/context.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/upstash/edge-flags/main/img/edge-flags/context.png -------------------------------------------------------------------------------- /img/edge-flags/created.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/upstash/edge-flags/main/img/edge-flags/created.png -------------------------------------------------------------------------------- /img/edge-flags/simple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/upstash/edge-flags/main/img/edge-flags/simple.png -------------------------------------------------------------------------------- /img/edge-flags/with-cdn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/upstash/edge-flags/main/img/edge-flags/with-cdn.png -------------------------------------------------------------------------------- /img/edge-flags/countries.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/upstash/edge-flags/main/img/edge-flags/countries.png -------------------------------------------------------------------------------- /img/edge-flags/percentage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/upstash/edge-flags/main/img/edge-flags/percentage.png -------------------------------------------------------------------------------- /examples/middleware-rewrites/app/page.tsx: -------------------------------------------------------------------------------- 1 | export default function Home() { 2 | return
Home
; 3 | } 4 | -------------------------------------------------------------------------------- /packages/sdk/src/environment.ts: -------------------------------------------------------------------------------- 1 | export const environments = ["production", "preview", "development"] as const; 2 | -------------------------------------------------------------------------------- /examples/nextjs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/upstash/edge-flags/main/examples/nextjs/public/favicon.ico -------------------------------------------------------------------------------- /packages/dashboard/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/upstash/edge-flags/main/packages/dashboard/src/app/favicon.ico -------------------------------------------------------------------------------- /examples/middleware-rewrites/app/blocked/page.tsx: -------------------------------------------------------------------------------- 1 | export default function Page() { 2 | return
You are not in the EU
; 3 | } 4 | -------------------------------------------------------------------------------- /packages/dashboard/next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {} 3 | 4 | export default nextConfig 5 | -------------------------------------------------------------------------------- /examples/middleware-rewrites/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/upstash/edge-flags/main/examples/middleware-rewrites/public/favicon.ico -------------------------------------------------------------------------------- /examples/middleware-rewrites/next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "packages/sdk" 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /examples/nextjs/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | }; 5 | 6 | module.exports = nextConfig; 7 | -------------------------------------------------------------------------------- /packages/dashboard/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @upstash/edge-flags-dashboard 2 | 3 | ## 0.1.1 4 | 5 | ### Patch Changes 6 | 7 | - 62278dc: add help to the cli and decrease bundle size 8 | -------------------------------------------------------------------------------- /examples/middleware-rewrites/README.md: -------------------------------------------------------------------------------- 1 | This example shows how to use an edge flag in a middleware to rewrite the request path. 2 | 3 | Check the `middleware.ts` file for details 4 | -------------------------------------------------------------------------------- /packages/dashboard/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | } 7 | 8 | export default config 9 | -------------------------------------------------------------------------------- /packages/sdk/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: "ts-jest", 4 | testEnvironment: "node", 5 | injectGlobals: false, 6 | }; 7 | -------------------------------------------------------------------------------- /packages/sdk/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./types"; 2 | export * from "./handler"; 3 | export * from "./admin"; 4 | export * from "./environment"; 5 | export * from "./hook"; 6 | export * from "./client"; 7 | export * from "./middleware"; 8 | -------------------------------------------------------------------------------- /examples/nextjs/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /examples/middleware-rewrites/app/layout.tsx: -------------------------------------------------------------------------------- 1 | export default function RootLayout({ 2 | children, 3 | }: { 4 | children: React.ReactNode; 5 | }) { 6 | return ( 7 | 8 | {children} 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /packages/sdk/src/types.ts: -------------------------------------------------------------------------------- 1 | import { flag } from "./flag"; 2 | import { rule } from "./rules"; 3 | import { z } from "zod"; 4 | 5 | export type Flag = z.infer; 6 | export type Environment = Flag["environment"]; 7 | export type Rule = z.infer; 8 | -------------------------------------------------------------------------------- /packages/sdk/tsup.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | entry: ["src/index.ts"], 5 | format: ["cjs", "esm"], 6 | splitting: false, 7 | sourcemap: true, 8 | clean: true, 9 | bundle: true, 10 | dts: true, 11 | }); 12 | -------------------------------------------------------------------------------- /packages/dashboard/src/server/credentials.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | export async function getCredentials() { 4 | // These will be used as the default credentials 5 | return { 6 | url: process.env.UPSTASH_REDIS_REST_URL, 7 | token: process.env.UPSTASH_REDIS_REST_TOKEN, 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /examples/nextjs/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # nextjs 2 | 3 | ## 0.1.2 4 | 5 | ### Patch Changes 6 | 7 | - Updated dependencies [92ad513] 8 | - @upstash/edge-flags@0.1.3 9 | 10 | ## 0.1.1 11 | 12 | ### Patch Changes 13 | 14 | - Updated dependencies [95d2e54] 15 | - Updated dependencies [c79a725] 16 | - @upstash/edge-flags@0.1.2 17 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.1/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "restricted", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /packages/dashboard/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss" 2 | 3 | const config: Config = { 4 | content: [ 5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | plugins: [], 10 | } 11 | export default config 12 | -------------------------------------------------------------------------------- /examples/middleware-rewrites/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # middleware-rewrites 2 | 3 | ## 0.1.2 4 | 5 | ### Patch Changes 6 | 7 | - Updated dependencies [92ad513] 8 | - @upstash/edge-flags@0.1.3 9 | 10 | ## 0.1.1 11 | 12 | ### Patch Changes 13 | 14 | - Updated dependencies [95d2e54] 15 | - Updated dependencies [c79a725] 16 | - @upstash/edge-flags@0.1.2 17 | -------------------------------------------------------------------------------- /examples/nextjs/styles/globals.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | padding: 0; 4 | margin: 0; 5 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 6 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 7 | } 8 | 9 | a { 10 | color: inherit; 11 | text-decoration: none; 12 | } 13 | 14 | * { 15 | box-sizing: border-box; 16 | } 17 | -------------------------------------------------------------------------------- /examples/nextjs/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from "react"; 2 | import "../styles/globals.css"; 3 | import "@tremor/react/dist/esm/tremor.css"; 4 | 5 | export default function Layout({ children }: PropsWithChildren) { 6 | return ( 7 | 8 | 9 | My App 10 | 11 | {children} 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /packages/dashboard/README.md: -------------------------------------------------------------------------------- 1 | # @upstash/edge-flags-dashboard 2 | 3 | This is a dashboard for creating and managing edge flags. 4 | 5 | You can use npx to run the self hosted version which also reads your redis credentials from the `.env` file in the working directory. 6 | 7 | ```bash 8 | npx @upstash/edge-flags-dashboard 9 | ``` 10 | 11 | You can also use the hosted version at https://edge-flags-dashboard.vercel.app 12 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "tasks": { 4 | "build": { 5 | "dependsOn": ["^build"], 6 | "outputs": ["dist/**", ".next/**"], 7 | "env": ["UPSTASH_REDIS_REST_URL", "UPSTASH_REDIS_REST_TOKEN"] 8 | }, 9 | "dev": { 10 | "cache": false 11 | }, 12 | "test": { 13 | "dependsOn": ["^build"], 14 | "cache": false 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/sdk/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @upstash/edge-flags 2 | 3 | ## 0.1.3 4 | 5 | ### Patch Changes 6 | 7 | - 92ad513: add memoization to attributes and the getFlag function in the hook 8 | 9 | ## 0.1.2 10 | 11 | ### Patch Changes 12 | 13 | - 95d2e54: change type of handler to support app router 14 | - c79a725: Expose the debug option in the sdk client object too 15 | 16 | ## 0.0.0 17 | 18 | ### Patch Changes 19 | 20 | - add changesets 21 | -------------------------------------------------------------------------------- /packages/dashboard/src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .edge-flags-percentage-slider .ant-slider-rail, 6 | .edge-flags-percentage-slider:hover .ant-slider-rail { 7 | @apply bg-blue-100; 8 | } 9 | 10 | .edge-flags-percentage-slider .ant-slider-track, 11 | .edge-flags-percentage-slider:hover .ant-slider-track { 12 | @apply bg-blue-400; 13 | } 14 | 15 | .edge-flags-percentage-slider .ant-slider-handle { 16 | @apply border-blue-400 shadow; 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # testing 9 | coverage 10 | 11 | # next.js 12 | .next/ 13 | out/ 14 | build 15 | 16 | # misc 17 | .DS_Store 18 | *.pem 19 | 20 | # debug 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | .pnpm-debug.log* 25 | 26 | # local env files 27 | .env* 28 | !.env.example 29 | 30 | # turbo 31 | .turbo 32 | .vercel 33 | 34 | dist 35 | 36 | 37 | 38 | .vscode -------------------------------------------------------------------------------- /rome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/rome/configuration_schema.json", 3 | "linter": { 4 | "enabled": true, 5 | "rules": { 6 | "recommended": true 7 | }, 8 | "ignore": [ 9 | "node_modules", 10 | ".next", 11 | "dist", 12 | ".turbo" 13 | ] 14 | }, 15 | "formatter": { 16 | "enabled": true, 17 | "lineWidth": 120, 18 | "indentStyle": "space", 19 | "ignore": [ 20 | "node_modules", 21 | ".next", 22 | "dist", 23 | ".turbo" 24 | ] 25 | } 26 | } -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /packages/sdk/src/flag.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { rule } from "./rules"; 3 | 4 | export const flag = z.object({ 5 | // The name must be unique per tenant but is shared across environments 6 | name: z.string(), 7 | version: z.enum(["v1"]), 8 | enabled: z.boolean(), 9 | rules: z.array(rule), 10 | environment: z.enum(["development", "preview", "production"]), 11 | percentage: z.number().min(0).max(100).nullable(), 12 | createdAt: z.number().int().positive(), 13 | updatedAt: z.number().int().positive(), 14 | }); 15 | -------------------------------------------------------------------------------- /examples/nextjs/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | -------------------------------------------------------------------------------- /packages/dashboard/src/components/input-label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { cx } from "class-variance-authority" 4 | 5 | export const InputLabel = ({ 6 | children, 7 | optional, 8 | className, 9 | }: { 10 | children: React.ReactNode 11 | optional?: boolean 12 | className?: string 13 | }) => { 14 | return ( 15 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /packages/dashboard/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /examples/middleware-rewrites/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /examples/nextjs/app/api/edge-flags/route.ts: -------------------------------------------------------------------------------- 1 | import { createEdgeHandler } from "@upstash/edge-flags"; 2 | 3 | export const GET = createEdgeHandler({ 4 | maxAge: 10, 5 | redisUrl: process.env.UPSTASH_REDIS_REST_URL!, 6 | redisToken: process.env.UPSTASH_REDIS_REST_TOKEN!, 7 | }); 8 | 9 | export const runtime = "edge"; 10 | 11 | // NOTE: For pages router, just default export the handler itself 12 | /* 13 | export default createEdgeHandler({ 14 | maxAge: 10, 15 | redisUrl: process.env.UPSTASH_REDIS_REST_URL!, 16 | redisToken: process.env.UPSTASH_REDIS_REST_TOKEN!, 17 | }); 18 | */ 19 | -------------------------------------------------------------------------------- /cmd/set-version.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | 4 | const root = process.argv[2]; // path to project root 5 | const version = process.argv[3].replace(/^v/, ""); // new version 6 | 7 | console.log(`Updating version=${version} in ${root}`); 8 | 9 | const content = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf-8")); 10 | 11 | content.version = version; 12 | 13 | fs.writeFileSync(path.join(root, "package.json"), JSON.stringify(content, null, 2)); 14 | fs.writeFileSync(path.join(root, "src", "version.ts"), `export const VERSION = "${version}";`); 15 | -------------------------------------------------------------------------------- /examples/middleware-rewrites/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "middleware-rewrites", 3 | "version": "0.1.2", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@types/node": "^20.14.9", 13 | "@types/react": "18.3.3", 14 | "@types/react-dom": "^18.3.0", 15 | "@upstash/edge-flags": "workspace:*", 16 | "@upstash/redis": "^1.31.6", 17 | "next": "14.2.35", 18 | "react": "^18.3.1", 19 | "react-dom": "18.3.1", 20 | "typescript": "5.5.2" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /examples/nextjs/README.md: -------------------------------------------------------------------------------- 1 | Use this project to test your flags and their latency. Check `app/edge-flags/route.ts` for the edge handler. 2 | 3 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fupstash%2Fedge-flags%2Ftree%2Fmain%2Fexamples%2Fnextjs&project-name=edge-flags&repository-name=edge-flags&redirect-url=https%3A%2F%2Fconsole.upstash.com%2Fedge-flags&developer-id=oac_V3R1GIpkoJorr6fqyiwdhl17&demo-title=Edge%20Flags&demo-description=Feature%20flags%20at%20the%20edge&demo-url=https%3A%2F%2Fedge-flags-nextjs.vercel.app&integration-ids=oac_V3R1GIpkoJorr6fqyiwdhl17) 4 | 5 | 6 | -------------------------------------------------------------------------------- /examples/middleware-rewrites/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@upstash/edge-flags", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "turbo run build", 7 | "test": "turbo run test", 8 | "dev": "turbo run dev --parallel", 9 | "fmt": "pnpm rome check . --apply-suggested && pnpm rome format . --write", 10 | "ci:version": "pnpm changeset version && pnpm install", 11 | "ci:build": "turbo build -F './packages/*'" 12 | }, 13 | "devDependencies": { 14 | "@changesets/cli": "^2.27.6", 15 | "rome": "^11.0.0", 16 | "turbo": "^2.0.5" 17 | }, 18 | "engines": { 19 | "node": ">=16.0.0" 20 | }, 21 | "packageManager": "pnpm@9.0.0" 22 | } 23 | -------------------------------------------------------------------------------- /examples/nextjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs", 3 | "version": "0.1.2", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@heroicons/react": "^2.0.13", 13 | "@tremor/react": "^1.6.0", 14 | "@upstash/edge-flags": "workspace:*", 15 | "next": "14.2.35", 16 | "react": "^18.3.1", 17 | "react-dom": "^18.2.0" 18 | }, 19 | "devDependencies": { 20 | "@types/node": "^18.11.18", 21 | "@types/react": "^18.3.1", 22 | "@types/react-dom": "^18.0.10", 23 | "typescript": "^5" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /examples/nextjs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /packages/dashboard/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./src/*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /examples/middleware-rewrites/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /docs/percentage.md: -------------------------------------------------------------------------------- 1 | # Percentage 2 | 3 | Using a percentage allows you to only apply rules to a subset of users. This is 4 | useful if you want to roll out a feature to a small percentage of users first 5 | and then increase it over time. 6 | 7 | Adding a percentage to a flag can be done in the top half of the flag editor. 8 | 9 | ![Percentage](/img/edge-flags/percentage.png) 10 | 11 | The percentage is applied to the flag before the rules are evaluated. If the 12 | percentage is 80%, the flag will be evaluated for 80% of the users and disabled 13 | for the other 20%. 14 | 15 | Set the percentage to 0 or click the small trash icon to remove the percentage. 16 | 17 | If no percentage is set, the flag will be evaluated for all users. 18 | -------------------------------------------------------------------------------- /packages/dashboard/src/api/query-client.ts: -------------------------------------------------------------------------------- 1 | import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query" 2 | import { notification } from "antd" 3 | 4 | export const queryClient = new QueryClient({ 5 | defaultOptions: { 6 | queries: { 7 | refetchOnMount: false, 8 | retry: false, 9 | }, 10 | mutations: { 11 | retry: false, 12 | }, 13 | }, 14 | queryCache: new QueryCache({ 15 | onError: handleError, 16 | }), 17 | mutationCache: new MutationCache({ 18 | onError: handleError, 19 | }), 20 | }) 21 | 22 | function handleError(error: Error) { 23 | notification.error({ 24 | message: "Error", 25 | description: error.message, 26 | placement: "bottomRight", 27 | }) 28 | 29 | console.error(error) 30 | } 31 | -------------------------------------------------------------------------------- /packages/sdk/src/cache.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A simple cache implementation with ttl support 3 | */ 4 | export class Cache { 5 | private readonly ttl?: number; 6 | private map: Map; 7 | constructor(ttl?: number) { 8 | this.ttl = ttl && ttl > 0 ? ttl : undefined; 9 | this.map = new Map(); 10 | } 11 | set(key: string, value: T) { 12 | this.map.set(key, { 13 | createdAt: Date.now(), 14 | value, 15 | }); 16 | } 17 | 18 | get(key: string): T | null { 19 | const item = this.map.get(key); 20 | if (!item) { 21 | return null; 22 | } 23 | if (this.ttl && (item.createdAt + this.ttl) < Date.now()) { 24 | this.map.delete(key); 25 | return null; 26 | } 27 | return item.value; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /examples/middleware-rewrites/middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { Redis } from "@upstash/redis"; 3 | import { Client as EdgeFlags } from "@upstash/edge-flags"; 4 | 5 | const edgeFlags = new EdgeFlags({ 6 | redis: Redis.fromEnv(), 7 | // You can enable debug mode to see the logs 8 | debug: true, 9 | }); 10 | 11 | export default async function middleware( 12 | req: NextRequest 13 | ): Promise { 14 | // req.geo object is provided by nextjs 15 | // it only works when deployed to Vercel 16 | const enabled = await edgeFlags 17 | .getFlag("eu-countries", req.geo ?? {}) 18 | .catch((err) => { 19 | console.error(err); 20 | return false; 21 | }); 22 | 23 | if (!enabled) { 24 | const url = new URL(req.url); 25 | url.pathname = "/blocked"; 26 | return NextResponse.rewrite(url); 27 | } 28 | 29 | return NextResponse.next(); 30 | } 31 | 32 | export const config = { 33 | matcher: "/", 34 | }; 35 | -------------------------------------------------------------------------------- /packages/dashboard/src/components/add-flag.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useState } from "react"; 3 | import { IconPlus } from "@tabler/icons-react"; 4 | import { Button, Input } from "antd"; 5 | import { useCreateFlag } from "@/api/flags"; 6 | 7 | export const AddFlagForm = () => { 8 | const [flagName, setFlagName] = useState(""); 9 | 10 | const { mutateAsync: createFlag, isPending } = useCreateFlag(); 11 | 12 | return ( 13 |
14 | setFlagName(e.target.value)} 17 | className="flex-grow" 18 | placeholder="Flag name" 19 | disabled={isPending} /> 20 | 26 |
27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /packages/dashboard/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import type { Metadata } from "next" 4 | import { Inter } from "next/font/google" 5 | 6 | import "./globals.css" 7 | 8 | import { queryClient } from "@/api/query-client" 9 | import { QueryClientProvider } from "@tanstack/react-query" 10 | import { ConfigProvider } from "antd" 11 | import colors from "tailwindcss/colors" 12 | 13 | const inter = Inter({ subsets: ["latin"] }) 14 | 15 | export default function RootLayout({ 16 | children, 17 | }: Readonly<{ 18 | children: React.ReactNode 19 | }>) { 20 | return ( 21 | 22 | 23 | 30 | {children} 31 | 32 | 33 | 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /packages/sdk/src/evaluation.ts: -------------------------------------------------------------------------------- 1 | import { EvalRequest, Rule } from "./rules"; 2 | import { Flag } from "./types"; 3 | 4 | export function evaluate(flag: Flag, userPercentage: number, req: EvalRequest, debug?: boolean): boolean { 5 | if (debug) console.log("Evaluating flag", JSON.stringify(flag)); 6 | 7 | if (flag.percentage) { 8 | if (debug) console.log({ userPercentage, flagPercentage: flag.percentage }); 9 | 10 | if (userPercentage > flag.percentage) { 11 | if (debug) console.log("userPercentage < flag.percentage, returning false"); 12 | return false; 13 | } 14 | } 15 | if (debug) console.log(JSON.stringify({ evalRequest: req.eval }, null, 2)); 16 | 17 | for (const rule of flag.rules) { 18 | const hit = new Rule(rule).match(req); 19 | if (debug) console.log("matching rule", rule, { hit }); 20 | 21 | if (hit) { 22 | if (debug) console.log("Returning", rule.value); 23 | 24 | return rule.value; 25 | } 26 | } 27 | 28 | /** 29 | * No rule applied 30 | */ 31 | 32 | return flag.enabled; 33 | } 34 | -------------------------------------------------------------------------------- /packages/dashboard/src/components/empty.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { IconPlus } from "@tabler/icons-react" 4 | import { Button, Empty } from "antd" 5 | 6 | import { AddDatabaseModal } from "./add-db-modal" 7 | 8 | export const EmptyDatabases = () => { 9 | return ( 10 | 21 |

No databases added yet

22 | 23 | } 24 | > 25 |
26 | 27 | 30 | 31 |
32 |
33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /packages/dashboard/src/hooks/use-redis.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, PropsWithChildren, useContext, useMemo } from "react" 2 | import { Admin } from "@upstash/edge-flags" 3 | import { Redis } from "@upstash/redis" 4 | 5 | const RedisContext = createContext< 6 | | { 7 | redis: Redis 8 | flags: Admin 9 | } 10 | | undefined 11 | >(undefined) 12 | 13 | export const RedisProvider = ({ 14 | redis, 15 | children, 16 | prefix, 17 | tenant, 18 | }: PropsWithChildren<{ 19 | redis: Redis 20 | prefix?: string 21 | tenant?: string 22 | }>) => { 23 | const flags = useMemo( 24 | () => 25 | new Admin({ 26 | redis: redis, 27 | prefix, 28 | tenant, 29 | }), 30 | [prefix, redis, tenant] 31 | ) 32 | 33 | return {children} 34 | } 35 | 36 | export const useRedis = () => { 37 | const context = useContext(RedisContext) 38 | if (!context) { 39 | throw new Error("useRedis must be used within a RedisProvider") 40 | } 41 | 42 | return context 43 | } 44 | -------------------------------------------------------------------------------- /packages/dashboard/prettier.config.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('prettier').Config & import('prettier-plugin-tailwindcss').options & 3 | * import("@ianvs/prettier-plugin-sort-imports").PluginConfig} 4 | */ 5 | const config = { 6 | endOfLine: "lf", 7 | semi: false, 8 | singleQuote: false, 9 | tabWidth: 2, 10 | trailingComma: "es5", 11 | printWidth: 100, 12 | arrowParens: "always", 13 | importOrder: [ 14 | "^(react/(.*)$)|^(react$)", 15 | "^(next/(.*)$)|^(next$)", 16 | "", 17 | "", 18 | "^types$", 19 | "^@/types/(.*)$", 20 | "^@/config/(.*)$", 21 | "^@/lib/(.*)$", 22 | "^@/hooks/(.*)$", 23 | "^@/components/ui/(.*)$", 24 | "^@/components/(.*)$", 25 | "^@/services/(.*)$", 26 | "^@/constants/(.*)$", 27 | "^@/registry/(.*)$", 28 | "^@/styles/(.*)$", 29 | "^@/app/(.*)$", 30 | "", 31 | "^[./]", 32 | ], 33 | importOrderParserPlugins: ["typescript", "jsx", "decorators-legacy"], 34 | plugins: ["@ianvs/prettier-plugin-sort-imports", "prettier-plugin-tailwindcss"], 35 | } 36 | 37 | export default config 38 | -------------------------------------------------------------------------------- /examples/nextjs/public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /packages/sdk/src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | 3 | export function edgeFlagsMiddleware(req: NextRequest): NextResponse { 4 | const url = new URL(req.url); 5 | 6 | console.log("Preparing geo data"); 7 | console.log(JSON.stringify({ geo: req.geo })); 8 | 9 | if (typeof req.geo?.city !== "undefined") { 10 | url.searchParams.set("city", req.geo.city); 11 | } 12 | 13 | if (typeof req.geo?.country !== "undefined") { 14 | url.searchParams.set("country", req.geo?.country); 15 | } 16 | 17 | if (typeof req.geo?.region !== "undefined") { 18 | url.searchParams.set("region", req.geo.region); 19 | } 20 | 21 | if (typeof req.geo?.latitude !== "undefined") { 22 | url.searchParams.set("latitude", req.geo.latitude); 23 | } 24 | 25 | if (typeof req.geo?.longitude !== "undefined") { 26 | url.searchParams.set("longitude", req.geo.longitude); 27 | } 28 | 29 | if (typeof req.ip !== "undefined") { 30 | url.searchParams.set("ip", req.ip); 31 | } 32 | 33 | console.log("RW", url.href); 34 | 35 | return NextResponse.rewrite(url); 36 | } 37 | -------------------------------------------------------------------------------- /examples/middleware-rewrites/public/thirteen.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/react.md: -------------------------------------------------------------------------------- 1 | # React Client 2 | 3 | The `@upstash/edge-flags` package provides a React hook, you can use to query 4 | flags in your React application. 5 | 6 | ## Installation 7 | 8 | ```bash 9 | npm install @upstash/edge-flags 10 | ``` 11 | 12 | ## Usage 13 | 14 | Import the `useFlag` hook from the `@upstash/edge-flags` package and pass the 15 | name of the flag you want to query. 16 | 17 | ```tsx 18 | import { useFlag } from "@upstash/edge-flags"; 19 | 20 | const MyComponent = () => { 21 | const { isEnabled, loading, error } = useFlag("my-flag"); 22 | 23 | if (error) { 24 | return

Error {error}

; 25 | } 26 | 27 | if (loading) { 28 | return

Loading...

; 29 | } 30 | 31 | return

Flag is {isEnabled ? "enabled" : "disabled"}

; 32 | }; 33 | ``` 34 | 35 | ## Custom Attributes 36 | 37 | You can use custom attributes to enable a flag for a subset of users. For 38 | example, you can identify users by `userId` or `email`. 39 | 40 | To set attributes, you can pass an object to the `useFlag` hook. 41 | 42 | ```tsx 43 | import { useFlag } from "@upstash/edge-flags"; 44 | 45 | const { isEnabled } = useFlag("my-flag", { 46 | userId: "chronark", 47 | email: "andreas@upstash.com", 48 | }); 49 | ``` 50 | -------------------------------------------------------------------------------- /docs/overview.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | Edge Flags is a low latency feature flagging solution running at the edge and 4 | storing data in a global Redis database. It is designed to be used with 5 | [Next.js](https://nextjs.org) and [Vercel](https://vercel.com) but we will soon 6 | roll out support for other popular frameworks and platforms. Let us know what 7 | you are looking for! 8 | 9 | You can find the Github Repository [here](https://github.com/upstash/edge-flags). 10 | 11 | ## Features 12 | 13 | - **Global Low latency:** Flags are stored in a global Redis database and are 14 | evaluated at the edge. 15 | - **Environments:** Flags have different environments to support your deployment 16 | process: `production`, `preview`, `development` 17 | - **Flexible:** Flags support geo targeting, percentage based rollouts and 18 | custom attributes 19 | - **Manage:** Flags can be created and managed using the SDK or the self-hosted [dashboard](https://github.com/upstash/edge-flags/tree/main/packages/dashboard). 20 | - **Free:** Edge Flags is free to use. You only pay for the Redis database. 21 | - **Cache:** Flags can be cached for a short period of time to reduce the 22 | required requests to redis, making it cheaper to use. 23 | 24 |
25 | 26 | ## Architecture 27 | 28 | ![Architecture](/img/edge-flags/simple.png) 29 | -------------------------------------------------------------------------------- /packages/sdk/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@upstash/edge-flags", 3 | "publishConfig": { 4 | "access": "public" 5 | }, 6 | "version": "0.1.3", 7 | "main": "./dist/index.js", 8 | "types": "./dist/index.d.ts", 9 | "license": "MIT", 10 | "private": false, 11 | "keywords": [ 12 | "edge", 13 | "flags", 14 | "upstash", 15 | "serverless", 16 | "feature-flags", 17 | "nextjs" 18 | ], 19 | "bugs": { 20 | "url": "https://github.com/upstash/edge-flags/issues" 21 | }, 22 | "homepage": "https://github.com/upstash/edge-flags#readme", 23 | "files": [ 24 | "./dist/**" 25 | ], 26 | "author": "Andreas Thomas ", 27 | "scripts": { 28 | "build": "tsup", 29 | "test": "jest --collect-coverage" 30 | }, 31 | "devDependencies": { 32 | "@jest/globals": "^29.3.1", 33 | "@types/jest": "^29.2.5", 34 | "@types/node": "^18.11.18", 35 | "@types/react": "^18.0.26", 36 | "@types/react-dom": "^18.0.10", 37 | "dotenv": "^16.0.3", 38 | "jest": "^29.3.1", 39 | "ts-jest": "^29.0.3", 40 | "tsup": "^6.2.3", 41 | "tsx": "^3.12.1", 42 | "typescript": "^4.9.4" 43 | }, 44 | "optionalDependencies": { 45 | "next": "^14" 46 | }, 47 | "dependencies": { 48 | "react": "^18.2.0", 49 | "@upstash/redis": "^1.19.3", 50 | "zod": "^3.20.0" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /.github/workflows/changeset.yml: -------------------------------------------------------------------------------- 1 | # most of this is copied from https://github.com/t3-oss/create-t3-app/blob/next/.github/workflows/release.yml 2 | name: Prepare Release 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | 9 | concurrency: ${{ github.workflow }}-${{ github.ref }} 10 | 11 | jobs: 12 | pr: 13 | name: Release Packages 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout Repo 17 | uses: actions/checkout@v3 18 | 19 | - name: Use PNPM 20 | uses: pnpm/action-setup@v3.0.0 21 | with: 22 | version: 9.0.0 23 | 24 | - name: Use Node.js 18 25 | uses: actions/setup-node@v3 26 | with: 27 | node-version: 18 28 | cache: "pnpm" 29 | 30 | - name: Install 31 | run: pnpm install 32 | 33 | - name: Build packages 34 | run: pnpm ci:build 35 | 36 | - name: Create Version PR or Publish to NPM 37 | id: changesets 38 | uses: changesets/action@v1.4.1 39 | with: 40 | commit: "chore(release): version packages" 41 | title: "chore(release): version packages" 42 | version: pnpm ci:version 43 | publish: pnpm changeset publish 44 | env: 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 47 | DISABLE_HUSKY: true 48 | -------------------------------------------------------------------------------- /examples/middleware-rewrites/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/sdk/src/client.ts: -------------------------------------------------------------------------------- 1 | import { Redis } from "@upstash/redis"; 2 | import { Admin } from "./admin"; 3 | import { evaluate } from "./evaluation"; 4 | import { EvalRequest } from "./rules"; 5 | import { Environment } from "./types"; 6 | 7 | export type ClientConfig = { 8 | redis: Redis; 9 | environment?: Environment; 10 | debug?: boolean; 11 | }; 12 | 13 | export class Client { 14 | private admin: Admin; 15 | private readonly environment: Environment; 16 | private readonly debug: boolean; 17 | 18 | constructor(config: ClientConfig) { 19 | this.admin = new Admin({ redis: config.redis }); 20 | this.environment = 21 | config.environment ?? 22 | (process.env.VERCEL_ENV as Environment) ?? 23 | "development"; 24 | this.debug = Boolean(config.debug); 25 | } 26 | 27 | async getFlag(flagName: string, req: EvalRequest): Promise { 28 | const flag = await this.admin.getFlag(flagName, this.environment); 29 | 30 | if (!flag) { 31 | throw new Error(`Flag ${flagName} not found`); 32 | } 33 | 34 | const hash = await crypto.subtle.digest( 35 | "SHA-256", 36 | new TextEncoder().encode(JSON.stringify(req)) 37 | ); 38 | const hashSum = Array.from(new Uint8Array(hash)).reduce((sum, x) => { 39 | sum += x; 40 | return sum; 41 | }, 0); 42 | 43 | const percentage = hashSum % 100; 44 | 45 | return evaluate(flag, percentage, req, this.debug); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /docs/environments.md: -------------------------------------------------------------------------------- 1 | # Environments 2 | 3 | When you create a new flag, it will always be created in all 3 environments: 4 | `production`, `preview` and `development`. You can then toggle the flag and set 5 | rules for each environment individually. 6 | 7 | Changing rules in one environment does not affect the other environments. This 8 | allows you to test your feature for example in the `preview` environment before 9 | enabling it in `production`. 10 | 11 | The context menu in the top right corner allows you to copy rules from one 12 | environment to another. This will overwrite the rules in the target environment, 13 | please be careful. 14 | 15 | ![Context](/img/edge-flags/context.png) 16 | 17 | ## Using environments 18 | 19 | 20 | If you deploy to Vercel, the SDK will automatically detect the environment and 21 | use the correct flag. 22 | 23 | 24 | When you use the SDK in your application, you can manually specify the 25 | environment you want to use: 26 | 27 | ```ts 28 | // /api/edge-flags.ts 29 | import { createEdgeHandler } from "@upstash/edge-flags"; 30 | 31 | export default createEdgeHandler({ 32 | // ... omitted for brevity 33 | environment: "preview", 34 | }); 35 | 36 | // ... 37 | ``` 38 | 39 | If you do not specify an environment, the SDK will try to figure out the 40 | environment based on the `VERCEL_ENV` environment variable. If it is neither 41 | specified nor `VERCEL_ENV` is set, it will default to `development`. 42 | -------------------------------------------------------------------------------- /packages/dashboard/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@upstash/edge-flags-dashboard", 3 | "version": "0.1.1", 4 | "bin": "./bin.cjs", 5 | "publishConfig": { 6 | "access": "public" 7 | }, 8 | "files": [ 9 | ".next", 10 | "bin.cjs", 11 | "!.next/cache" 12 | ], 13 | "scripts": { 14 | "dev": "next dev", 15 | "build": "next build", 16 | "start": "next start", 17 | "lint": "next lint", 18 | "format": "prettier --write ." 19 | }, 20 | "dependencies": { 21 | "dotenv": "^16.0.3", 22 | "minimist": "^1.2.8", 23 | "next": "14.2.35", 24 | "opener": "^1.5.2" 25 | }, 26 | "devDependencies": { 27 | "@hello-pangea/dnd": "^16.6.0", 28 | "@ianvs/prettier-plugin-sort-imports": "^4.2.1", 29 | "@tabler/icons-react": "^3.6.0", 30 | "@tanstack/react-query": "^5.45.1", 31 | "@types/node": "^20", 32 | "@types/react": "^18.3.1", 33 | "@types/react-beautiful-dnd": "^13.1.8", 34 | "@types/react-dom": "^18", 35 | "@upstash/edge-flags": "workspace:*", 36 | "@upstash/redis": "^1.31.5", 37 | "antd": "^5.18.3", 38 | "class-variance-authority": "^0.7.0", 39 | "eslint": "^8", 40 | "eslint-config-next": "^14.2.4", 41 | "framer-motion": "^11.2.11", 42 | "postcss": "^8", 43 | "prettier": "^3.3.2", 44 | "prettier-plugin-tailwindcss": "^0.6.5", 45 | "react": "^18.3.1", 46 | "react-beautiful-dnd": "^13.1.1", 47 | "react-dom": "^18", 48 | "react-hook-form": "^7.52.0", 49 | "tailwindcss": "^3.4.1", 50 | "typescript": "^5", 51 | "zod": "^3.23.8", 52 | "zustand": "^4.5.2" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /packages/dashboard/src/lib/database-store.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand" 2 | import { persist } from "zustand/middleware" 3 | 4 | type Database = { 5 | id: string 6 | 7 | url: string 8 | token: string 9 | tenant: string 10 | prefix: string 11 | saveToLocalStorage: boolean 12 | } 13 | 14 | type Store = { 15 | databases: Database[] 16 | 17 | addDatabase: (database: Omit) => void 18 | deleteDatabase: (id: string) => void 19 | } 20 | 21 | export const useDatabaseStore = create( 22 | persist( 23 | (set, get) => ({ 24 | databases: [], 25 | 26 | addDatabase: (database) => { 27 | if (get().databases.some((db) => db.url === database.url)) { 28 | throw new Error("Database with the same URL already exists") 29 | } 30 | 31 | const db = { 32 | id: Math.random().toString(36).slice(2, 9), 33 | ...database, 34 | } 35 | 36 | set((state) => ({ 37 | databases: [...state.databases, db], 38 | })) 39 | }, 40 | 41 | deleteDatabase: (id) => { 42 | if (!get().databases.some((db) => db.id === id)) { 43 | throw new Error("Database with the given ID does not exist") 44 | } 45 | 46 | set((state) => ({ 47 | databases: state.databases.filter((db) => db.id !== id), 48 | })) 49 | }, 50 | }), 51 | { 52 | name: "edge-flags", 53 | 54 | // Only save databases with `saveToLocalStorage` set to true 55 | getStorage: () => localStorage, 56 | partialize: (state) => ({ 57 | ...state, 58 | databases: state.databases.filter((db) => db.saveToLocalStorage), 59 | customField: true, 60 | }), 61 | } 62 | ) 63 | ) 64 | -------------------------------------------------------------------------------- /packages/dashboard/bin.cjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const { createServer } = require("http") 4 | const { parse } = require("url") 5 | const next = require("next") 6 | const opener = require("opener") 7 | 8 | // Read environment variables from where binary is run, 9 | // not from the next deployment directory 10 | require("dotenv").config() 11 | 12 | // CD into the script path 13 | process.chdir(__dirname) 14 | 15 | const hostname = "localhost" 16 | 17 | const argv = require("minimist")(process.argv.slice(2), { 18 | alias: { 19 | p: "port", 20 | h: "help", 21 | }, 22 | }) 23 | 24 | if (argv.help) { 25 | console.log( 26 | ` 27 | Usage: @upstash/edge-flags-dashboard [options] 28 | 29 | Options: 30 | -p, --port Port number to listen on 31 | -h, --help Show this help message 32 | `.trimStart() 33 | ) 34 | process.exit(0) 35 | } 36 | 37 | const port = parseInt(argv.port || process.env.PORT || "5434", 10) 38 | 39 | const app = next({ dev: false, hostname, port }) 40 | const handle = app.getRequestHandler() 41 | 42 | app.prepare().then(() => { 43 | createServer(async (req, res) => { 44 | try { 45 | // Be sure to pass `true` as the second argument to `url.parse`. 46 | // This tells it to parse the query portion of the URL. 47 | const parsedUrl = parse(req.url, true) 48 | 49 | await handle(req, res, parsedUrl) 50 | } catch (err) { 51 | console.error("Error occurred handling", req.url, err) 52 | res.statusCode = 500 53 | res.end("internal server error") 54 | } 55 | }) 56 | .once("error", (err) => { 57 | console.error(err) 58 | process.exit(1) 59 | }) 60 | .listen(port, () => { 61 | console.log(`> Ready on http://${hostname}:${port}`) 62 | 63 | opener(`http://${hostname}:${port}`) 64 | }) 65 | }) 66 | -------------------------------------------------------------------------------- /examples/nextjs/styles/Home.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 0 2rem; 3 | } 4 | 5 | .main { 6 | min-height: 100vh; 7 | padding: 4rem 0; 8 | flex: 1; 9 | display: flex; 10 | flex-direction: column; 11 | justify-content: center; 12 | align-items: center; 13 | } 14 | 15 | .footer { 16 | display: flex; 17 | flex: 1; 18 | padding: 2rem 0; 19 | border-top: 1px solid #eaeaea; 20 | justify-content: center; 21 | align-items: center; 22 | } 23 | 24 | .footer a { 25 | display: flex; 26 | justify-content: center; 27 | align-items: center; 28 | flex-grow: 1; 29 | } 30 | 31 | .title a { 32 | color: #0070f3; 33 | text-decoration: none; 34 | } 35 | 36 | .title a:hover, 37 | .title a:focus, 38 | .title a:active { 39 | text-decoration: underline; 40 | } 41 | 42 | .title { 43 | margin: 0; 44 | line-height: 1.15; 45 | font-size: 4rem; 46 | } 47 | 48 | .title, 49 | .description { 50 | text-align: center; 51 | } 52 | 53 | .description { 54 | margin: 4rem 0; 55 | line-height: 1.5; 56 | font-size: 1.5rem; 57 | } 58 | 59 | .code { 60 | background: #fafafa; 61 | border-radius: 5px; 62 | padding: 0.75rem; 63 | font-size: 1.1rem; 64 | font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, 65 | Bitstream Vera Sans Mono, Courier New, monospace; 66 | } 67 | 68 | .grid { 69 | display: flex; 70 | align-items: center; 71 | justify-content: center; 72 | flex-wrap: wrap; 73 | max-width: 800px; 74 | } 75 | 76 | .card { 77 | margin: 1rem; 78 | padding: 1.5rem; 79 | text-align: left; 80 | color: inherit; 81 | text-decoration: none; 82 | border: 1px solid #eaeaea; 83 | border-radius: 10px; 84 | transition: color 0.15s ease, border-color 0.15s ease; 85 | max-width: 300px; 86 | } 87 | 88 | .card:hover, 89 | .card:focus, 90 | .card:active { 91 | color: #0070f3; 92 | border-color: #0070f3; 93 | } 94 | 95 | .card h2 { 96 | margin: 0 0 1rem 0; 97 | font-size: 1.5rem; 98 | } 99 | 100 | .card p { 101 | margin: 0; 102 | font-size: 1.25rem; 103 | line-height: 1.5; 104 | } 105 | 106 | .logo { 107 | height: 1em; 108 | margin-left: 0.5rem; 109 | } 110 | 111 | @media (max-width: 600px) { 112 | .grid { 113 | width: 100%; 114 | flex-direction: column; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /packages/dashboard/src/components/rename-flag-modal.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useEffect } from "react" 4 | import { useRenameFlag } from "@/api/flags" 5 | import { Flag } from "@upstash/edge-flags" 6 | import { Input, Modal } from "antd" 7 | import { Controller, useForm } from "react-hook-form" 8 | 9 | import { InputLabel } from "@/components/input-label" 10 | 11 | type FormValues = { 12 | name: string 13 | } 14 | 15 | export const RenameFlagModal = ({ 16 | selectedFlag, 17 | onClose, 18 | }: { 19 | selectedFlag?: Flag 20 | onClose: () => void 21 | }) => { 22 | const { 23 | handleSubmit, 24 | control, 25 | formState: { errors }, 26 | reset, 27 | } = useForm({ 28 | defaultValues: { 29 | name: selectedFlag?.name, 30 | }, 31 | }) 32 | 33 | useEffect(() => { 34 | if (selectedFlag) { 35 | reset({ 36 | name: selectedFlag.name, 37 | }) 38 | } 39 | }, [reset, selectedFlag]) 40 | 41 | const { mutateAsync: renameFlag, isPending } = useRenameFlag() 42 | 43 | const onSubmit = async (values: FormValues) => { 44 | if (!selectedFlag) throw new Error("No flag selected") 45 | await renameFlag({ 46 | oldName: selectedFlag.name, 47 | newName: values.name, 48 | }) 49 | } 50 | 51 | return ( 52 | 61 |
62 |
63 | { 69 | if (value === selectedFlag?.name) return "New name should be different" 70 | return true 71 | }, 72 | }} 73 | render={({ field }) => ( 74 | 80 | )} 81 | /> 82 | {errors.name && {errors.name.message}} 83 |
84 |
85 |
86 | ) 87 | } 88 | -------------------------------------------------------------------------------- /docs/getstarted.md: -------------------------------------------------------------------------------- 1 | # Get Started 2 | 3 | This quickstart will show you how to get started with Edge Flags in a Next.js 4 | project. 5 | 6 | ## 1. Create a redis database 7 | 8 | Go to [console.upstash.com/redis](https://console.upstash.com/redis) and create 9 | a new global database. 10 | 11 | After creating the db, copy the `UPSTASH_REDIS_REST_URL` and 12 | `UPSTASH_REDIS_REST_TOKEN` to your `.env` file. 13 | 14 | ## 2. Create a flag 15 | 16 | Open the self-hosted edge flags dashboard using npx in the same directory and add your database. 17 | 18 | ```bash 19 | npx @upstash/edge-flags-dashboard 20 | ``` 21 | 22 | You can also use the hosted version at [https://edge-flags-dashboard.vercel.app](https://edge-flags-dashboard.vercel.app). 23 | 24 | Create a new flag using the dashboard and enable it. Then you can add some rules. 25 | 26 | ![Created Flag](/img/edge-flags/flag.png) 27 | 28 | In this case, the flag has a percentage and 2 rules. For 80% of the users the 29 | flag will be evaluated. For the other 20% the flag will immediately return 30 | `false` without evaluating the rules. 31 | 32 | - The first rule is a geo targeting rule. It will enable the flag for users in 33 | Germany or the United Kingdom. 34 | - The second rule is a custom attribute rule. It will enable the flag for users 35 | with the attribute `userId` set to `chronark`. 36 | 37 | Rules are evaluated from top to bottom. If a rule matches, the flag will return 38 | the configured value and stop evaluating the remaining rules. 39 | 40 | If neither rule matches, the flag will return `false` 41 | 42 | Make sure you have enabled the flag by clicking on the toggle button in the top 43 | right corner. 44 | 45 | Now lets use the flag in our Next.js project. 46 | 47 | ## 3. Install dependencies 48 | 49 | ```bash 50 | npm install @upstash/edge-flags 51 | ``` 52 | 53 | ## 4. Create an edge function in your project 54 | 55 | ```ts 56 | // /api/edge-flags/route.ts 57 | import { createEdgeHandler } from "@upstash/edge-flags"; 58 | 59 | export const GET = createEdgeHandler({ 60 | cacheMaxAge: 0, // cache time in seconds, 0 disables the cache 61 | redisUrl: process.env.UPSTASH_REDIS_REST_URL!, // omit to load from env automatically 62 | redisToken: process.env.UPSTASH_REDIS_REST_TOKEN!, // omit to load from env automatically 63 | }); 64 | 65 | export const runtime = "edge"; 66 | ``` 67 | 68 | ## 5. Query the flag in your frontend 69 | 70 | ```tsx 71 | // /app/page.tsx 72 | import { useFlag } from "@upstash/edge-flags"; 73 | 74 | export default function Example() { 75 | const { isEnabled, isLoading, error } = useFlag("flag-name"); 76 | 77 | if (error) return
Error: {error}
; 78 | if (isLoading) return
Loading...
; 79 | 80 | return
Is my feature enabled: {isEnabled}
; 81 | } 82 | ``` 83 | 84 | For more information about client side usage, see 85 | [here](/redis/sdks/edge-flags/react). 86 | -------------------------------------------------------------------------------- /packages/dashboard/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useEffect, useMemo, useState } from "react" 4 | import { useFetchFlags } from "@/api/flags" 5 | import { Redis } from "@upstash/redis" 6 | import { Skeleton } from "antd" 7 | 8 | import { useDatabaseStore } from "@/lib/database-store" 9 | import { RedisProvider } from "@/hooks/use-redis" 10 | import { EmptyDatabases } from "@/components/empty" 11 | import { FeatureFlag } from "@/components/flag" 12 | 13 | import { AddFlagForm } from "../components/add-flag" 14 | import { DatabaseSelector, EnvironmentType } from "../components/select-db" 15 | 16 | const FlagsList = ({ 17 | environment, 18 | selectedDb, 19 | }: { 20 | environment: EnvironmentType 21 | selectedDb?: string 22 | }) => { 23 | const { data: flags, isLoading } = useFetchFlags({ 24 | selectedDb, 25 | }) 26 | 27 | if (isLoading) { 28 | return 29 | } 30 | 31 | return ( 32 |
33 | {flags 34 | ?.filter((flag) => flag.environment === environment) 35 | .map((flag) => )} 36 |
37 | ) 38 | } 39 | 40 | export default function HydrationWrapper() { 41 | const [isHydrated, setIsHydrated] = useState(false) 42 | 43 | useEffect(() => { 44 | useDatabaseStore.persist.rehydrate() 45 | setIsHydrated(true) 46 | }, []) 47 | 48 | if (!isHydrated) { 49 | return null 50 | } 51 | 52 | return 53 | } 54 | 55 | function Page() { 56 | const [selectedDb, setSelectedDb] = useState() 57 | const [environment, setEnvironment] = useState("production") 58 | 59 | const { databases } = useDatabaseStore() 60 | 61 | const selectedRedis = useMemo(() => { 62 | const db = databases.find((db) => db.id === selectedDb) 63 | 64 | if (!db) return 65 | 66 | return new Redis({ 67 | url: db.url, 68 | token: db.token, 69 | retry: false, 70 | }) 71 | }, [databases, selectedDb]) 72 | 73 | return ( 74 |
75 |
76 | {databases.length === 0 ? ( 77 | 78 | ) : ( 79 |
80 | 86 | {selectedRedis && ( 87 | 88 | 89 | 90 | 91 | )} 92 |
93 | )} 94 |
95 |
96 | ) 97 | } 98 | -------------------------------------------------------------------------------- /packages/dashboard/src/api/flags.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQuery } from "@tanstack/react-query" 2 | import { Admin, Environment, Rule } from "@upstash/edge-flags" 3 | 4 | import { useRedis } from "@/hooks/use-redis" 5 | 6 | import { queryClient } from "./query-client" 7 | 8 | export type FlagData = Awaited> 9 | 10 | export const useFetchFlags = ({ selectedDb }: { selectedDb?: string }) => { 11 | const { flags } = useRedis() 12 | 13 | return useQuery({ 14 | queryKey: ["flags-list", selectedDb], 15 | queryFn: async () => { 16 | const list = await flags.listFlags() 17 | 18 | return list.sort((a, b) => a.name.localeCompare(b.name)) 19 | }, 20 | }) 21 | } 22 | 23 | const invalidateFlagsList = () => { 24 | queryClient.invalidateQueries({ 25 | queryKey: ["flags-list"], 26 | }) 27 | } 28 | 29 | export const useCreateFlag = () => { 30 | const { flags } = useRedis() 31 | 32 | return useMutation({ 33 | mutationFn: flags.createFlag.bind(flags), 34 | onSuccess: invalidateFlagsList, 35 | }) 36 | } 37 | 38 | export const useUpdateFlag = () => { 39 | const { flags } = useRedis() 40 | 41 | return useMutation({ 42 | mutationFn: async ({ 43 | name, 44 | environment, 45 | data, 46 | }: { 47 | name: string 48 | environment: Environment 49 | data: { 50 | enabled?: boolean 51 | rules?: Rule[] 52 | percentage?: number | null 53 | } 54 | }) => flags.updateFlag(name, environment, data), 55 | onSuccess: invalidateFlagsList, 56 | }) 57 | } 58 | 59 | export const useDeleteFlag = () => { 60 | const { flags } = useRedis() 61 | 62 | return useMutation({ 63 | mutationFn: flags.deleteFlag.bind(flags), 64 | onSuccess: invalidateFlagsList, 65 | }) 66 | } 67 | 68 | export const useRenameFlag = () => { 69 | const { flags } = useRedis() 70 | 71 | return useMutation({ 72 | mutationFn: ({ oldName, newName }: { oldName: string; newName: string }) => 73 | flags.renameFlag(oldName, newName), 74 | onSuccess: invalidateFlagsList, 75 | }) 76 | } 77 | 78 | export const useCopyFlag = () => { 79 | const { flags } = useRedis() 80 | 81 | return useMutation({ 82 | mutationFn: ({ flagName, newName }: { flagName: string; newName: string }) => 83 | flags.copyFlag(flagName, newName), 84 | onSuccess: invalidateFlagsList, 85 | }) 86 | } 87 | 88 | export const useCopyFlagToEnvironment = () => { 89 | const { flags } = useRedis() 90 | 91 | return useMutation({ 92 | mutationFn: ({ 93 | flagName, 94 | from, 95 | to, 96 | }: { 97 | flagName: string 98 | from: Environment 99 | to: Environment 100 | }) => flags.copyEnvironment(flagName, from, to), 101 | onSuccess: invalidateFlagsList, 102 | }) 103 | } 104 | -------------------------------------------------------------------------------- /packages/sdk/src/hook.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useMemo, useState } from "react"; 2 | 3 | export type UseFlag = { 4 | isLoading: boolean; 5 | error: string | null; 6 | isEnabled: boolean | null; 7 | refresh: () => Promise; 8 | /** 9 | * For development purposes only 10 | * 11 | * This can change at any time 12 | */ 13 | debug: { 14 | latency: { 15 | total: number | null; 16 | edge: number | null; 17 | redis: number | null; 18 | }; 19 | 20 | cache: { 21 | memory: string | null; 22 | vercel: string | null; 23 | }; 24 | }; 25 | }; 26 | 27 | export function useFlag( 28 | flag: string, 29 | attributes?: Record 30 | ): UseFlag { 31 | const [isLoading, setIsLoading] = useState(false); 32 | const [error, setError] = useState(null); 33 | const [isEnabled, setIsEnabled] = useState(null); 34 | const [latency, setLatency] = useState(null); 35 | const [redisLatency, setRedisLatency] = useState(null); 36 | const [edgeLatency, setEdgeLatency] = useState(null); 37 | const [memoryCacheHit, setMemoryCacheHit] = useState(null); 38 | const [vercelCacheHit, setVercelCacheHit] = useState(null); 39 | 40 | const memoizedAttributes = useMemo( 41 | () => attributes, 42 | [JSON.stringify(attributes)] 43 | ); 44 | 45 | const getFlag = useCallback(async () => { 46 | setError(null); 47 | setMemoryCacheHit(null); 48 | setVercelCacheHit(null); 49 | 50 | const now = Date.now(); 51 | try { 52 | setIsLoading(true); 53 | const params = new URLSearchParams(); 54 | 55 | const attributes = { ...memoizedAttributes, _flag: flag }; 56 | 57 | for (const [k, v] of Object.entries(attributes)) { 58 | params.set(k, v.toString()); 59 | } 60 | const res = await fetch(`/api/edge-flags?${params.toString()}`); 61 | if (!res.ok) { 62 | setError(await res.text()); 63 | return; 64 | } 65 | const json = (await res.json()) as { value: boolean }; 66 | setVercelCacheHit(res.headers.get("X-Vercel-Cache")); 67 | setMemoryCacheHit(res.headers.get("X-Edge-Flags-Cache")); 68 | setRedisLatency(parseInt(res.headers.get("X-Redis-Latency") ?? "-1")); 69 | setEdgeLatency(parseInt(res.headers.get("X-Edge-Latency") ?? "-1")); 70 | setIsEnabled(json.value); 71 | } catch (err) { 72 | if (err instanceof Error) { 73 | setError(err.message); 74 | } else { 75 | throw err; 76 | } 77 | } finally { 78 | setLatency(Date.now() - now); 79 | setIsLoading(false); 80 | } 81 | }, [flag, memoizedAttributes]); 82 | 83 | // Only updates when flag or attributes change 84 | useEffect(() => { 85 | getFlag(); 86 | }, [getFlag]); 87 | 88 | return { 89 | isLoading, 90 | error, 91 | isEnabled, 92 | refresh: getFlag, 93 | debug: { 94 | latency: { 95 | total: latency, 96 | edge: edgeLatency, 97 | redis: redisLatency, 98 | }, 99 | cache: { 100 | vercel: vercelCacheHit, 101 | memory: memoryCacheHit, 102 | }, 103 | }, 104 | }; 105 | } 106 | -------------------------------------------------------------------------------- /packages/dashboard/src/components/flag-dropdown.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react" 2 | import { useCopyFlag, useCopyFlagToEnvironment, useDeleteFlag } from "@/api/flags" 3 | import { IconDots } from "@tabler/icons-react" 4 | import { Environment, environments, Flag } from "@upstash/edge-flags" 5 | import { Button, Dropdown, Modal } from "antd" 6 | 7 | import { capitalizeString } from "./flag" 8 | import { RenameFlagModal } from "./rename-flag-modal" 9 | 10 | export const ActionsDropdown = ({ 11 | flag, 12 | environment, 13 | }: { 14 | flag: Flag 15 | environment: Environment 16 | }) => { 17 | const { mutate: duplicateFlag } = useCopyFlag() 18 | const { mutate: deleteFlag } = useDeleteFlag() 19 | const { mutate: copyFlagToEnv } = useCopyFlagToEnvironment() 20 | 21 | const [isRenameModalVisible, setIsRenameModalVisible] = useState(false) 22 | 23 | return ( 24 | <> 25 | { 34 | setIsRenameModalVisible(true) 35 | }, 36 | }, 37 | { 38 | key: "duplicate", 39 | label: "Duplicate", 40 | onClick: () => { 41 | duplicateFlag({ 42 | flagName: flag.name, 43 | newName: flag.name + "-copy", 44 | }) 45 | }, 46 | }, 47 | { 48 | type: "divider", 49 | }, 50 | ...environments 51 | .filter((env) => env !== environment) 52 | .map((targetEnv) => ({ 53 | key: "push-" + targetEnv, 54 | label: `Copy to ${capitalizeString(targetEnv)}`, 55 | onClick: () => { 56 | return Modal.confirm({ 57 | title: `Are you sure?`, 58 | okText: "Copy", 59 | content: ( 60 |
61 | Copying to{" "} 62 | 63 | {targetEnv} 64 | {" "} 65 | will overwrite any existing configuration. 66 |
67 | ), 68 | onOk: () => 69 | copyFlagToEnv({ 70 | flagName: flag.name, 71 | from: environment, 72 | to: targetEnv, 73 | }), 74 | }) 75 | }, 76 | })), 77 | { 78 | type: "divider", 79 | }, 80 | { 81 | key: "delete", 82 | label: "Delete", 83 | danger: true, 84 | onClick: () => { 85 | Modal.confirm({ 86 | title: "Are you sure delete this flag?", 87 | onOk: () => deleteFlag(flag.name), 88 | okText: "Delete", 89 | okButtonProps: { 90 | danger: true, 91 | }, 92 | }) 93 | }, 94 | }, 95 | ], 96 | }} 97 | > 98 | 83 | 84 | 85 | 86 | )} 87 | 88 | ) 89 | })} 90 | {droppableProvided.placeholder} 91 | 92 | )} 93 | 94 | 95 | 96 |
97 | 111 |
112 | 113 | ) 114 | } 115 | 116 | function RuleItemAccessor({ index }: { index: number }) { 117 | const { control, watch } = useFormContext() 118 | 119 | const accessor = watch(`rules[${index}].accessor`) 120 | const isCustomAccessor = !["city", "country", "identifier", "ip", "region"].includes(accessor) 121 | 122 | return ( 123 |
124 | If 125 | ( 129 | 177 | )} 178 | /> 179 |
180 | ) 181 | } 182 | 183 | function RuleItemTarget({ index }: { index: number }) { 184 | const { control, watch } = useFormContext() 185 | 186 | const accessor = watch(`rules[${index}].accessor`) 187 | const compare = watch(`rules[${index}].compare`) 188 | const isCompareArray = ["in", "not_in"].includes(compare) 189 | 190 | return ( 191 |
192 | { 196 | return isCompareArray ? ( 197 | 208 | ) 209 | }} 210 | /> 211 |
212 | ) 213 | } 214 | 215 | function RuleItemCustomName({ index }: { index: number }) { 216 | const { control } = useFormContext() 217 | 218 | return ( 219 |
220 | { 224 | return 225 | }} 226 | /> 227 |
228 | ) 229 | } 230 | 231 | function RuleItemValue({ index }: { index: number }) { 232 | const { control } = useFormContext() 233 | 234 | return ( 235 |
236 | then 237 | ( 241 |