├── examples └── react-vite │ ├── src │ ├── vite-env.d.ts │ ├── main.tsx │ ├── App.css │ ├── index.css │ ├── App.tsx │ └── assets │ │ └── react.svg │ ├── vite.config.ts │ ├── tsconfig.node.json │ ├── .gitignore │ ├── index.html │ ├── .eslintrc.cjs │ ├── tsconfig.json │ ├── package.json │ ├── README.md │ └── public │ └── vite.svg ├── docs └── img │ ├── .DS_Store │ └── keat-intro.png ├── .prettierrc ├── .gitignore ├── .changeset ├── selfish-schools-poke.md ├── config.json └── README.md ├── packages ├── keat-posthog │ ├── src │ │ ├── posthog.d.ts │ │ ├── posthog.ts │ │ └── posthog.js │ ├── tsconfig.json │ └── package.json ├── keat │ ├── src │ │ ├── index.ts │ │ ├── plugins │ │ │ ├── launchDay.ts │ │ │ ├── index.ts │ │ │ ├── audience.ts │ │ │ ├── timeInterval.ts │ │ │ ├── localConfig.ts │ │ │ ├── queryParam.ts │ │ │ ├── customConfig.ts │ │ │ ├── keatRelease.ts │ │ │ ├── schedule.ts │ │ │ ├── cache.ts │ │ │ ├── anonymous.ts │ │ │ ├── localStorage.ts │ │ │ ├── remoteConfig.ts │ │ │ └── rollouts.ts │ │ ├── matchers.ts │ │ ├── display.ts │ │ ├── types.ts │ │ ├── plugin.ts │ │ ├── utils.ts │ │ └── keat.ts │ ├── tsconfig.json │ └── package.json ├── keat-launchdarkly │ ├── tsconfig.json │ ├── package.json │ └── src │ │ └── launchdarkly.ts └── keat-react │ ├── tsconfig.json │ ├── package.json │ └── src │ └── index.tsx ├── turbo.json ├── package.json ├── .github └── workflows │ ├── test-pr.yaml │ └── release.yaml ├── LICENSE.md ├── CHANGELOG.md └── README.md /examples/react-vite/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /docs/img/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WitoDelnat/keat/HEAD/docs/img/.DS_Store -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | trailingComma: "es5" 2 | tabWidth: 4 3 | semi: false 4 | singleQuote: true 5 | -------------------------------------------------------------------------------- /docs/img/keat-intro.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WitoDelnat/keat/HEAD/docs/img/keat-intro.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/lib/**/* 2 | **/node_modules/** 3 | yarn-error.log 4 | .env 5 | .idea/** 6 | examples/react-release/** 7 | **/.turbo 8 | .DS_Store 9 | **/.DS_Store -------------------------------------------------------------------------------- /.changeset/selfish-schools-poke.md: -------------------------------------------------------------------------------- 1 | --- 2 | "keat": minor 3 | "keat-launchdarkly": minor 4 | "keat-posthig": minor 5 | "keat-react": minor 6 | --- 7 | 8 | chore: initial monorepo release 9 | -------------------------------------------------------------------------------- /packages/keat-posthog/src/posthog.d.ts: -------------------------------------------------------------------------------- 1 | import { PostHog, PostHogConfig } from "posthog-js"; 2 | export declare const posthog: (apiTokenOrClient: string | PostHog, options?: Partial) => any; 3 | -------------------------------------------------------------------------------- /packages/keat/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./keat"; 2 | export * from "./matchers" 3 | export * from "./plugins"; 4 | export * from "./plugin"; 5 | export * from "./types"; 6 | export * from "./utils"; 7 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "pipeline": { 4 | "build": { 5 | "outputs": ["lib/**"], 6 | "dependsOn": ["^build"] 7 | }, 8 | "lint": {} 9 | } 10 | } -------------------------------------------------------------------------------- /examples/react-vite/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "workspaces": [ 3 | "packages/*", 4 | "examples/*" 5 | ], 6 | "devDependencies": { 7 | "@changesets/cli": "2.26.2", 8 | "graphql": "16.6.0", 9 | "turborepo": "0.0.1", 10 | "typescript": "5.1.6" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /examples/react-vite/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /examples/react-vite/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App.tsx' 4 | import './index.css' 5 | 6 | ReactDOM.createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /packages/keat/src/plugins/launchDay.ts: -------------------------------------------------------------------------------- 1 | import {createPlugin} from "../plugin"; 2 | import {isDate} from "../matchers"; 3 | 4 | export const launchDay = () => { 5 | createPlugin({ 6 | matcher: isDate, 7 | evaluate({ literal }) { 8 | return literal.getTime() < Date.now(); 9 | }, 10 | }); 11 | }; 12 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.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 | -------------------------------------------------------------------------------- /examples/react-vite/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /examples/react-vite/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /packages/keat/src/plugins/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./anonymous"; 2 | export * from "./audience"; 3 | export * from "./cache"; 4 | export * from "./customConfig"; 5 | export * from "./keatRelease"; 6 | export * from "./launchDay"; 7 | export * from "./localConfig"; 8 | export * from "./localStorage"; 9 | export * from "./queryParam"; 10 | export * from "./remoteConfig"; 11 | export * from "./rollouts"; 12 | export * from "./schedule"; 13 | export * from "./timeInterval"; 14 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /examples/react-vite/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /packages/keat/src/plugins/audience.ts: -------------------------------------------------------------------------------- 1 | import {User} from "../types"; 2 | import {createPlugin} from "../plugin"; 3 | import {isString} from "../matchers"; 4 | 5 | type AudienceFn = (user: User) => boolean | undefined; 6 | 7 | export const audience = (name: string, fn: AudienceFn) => { 8 | return createPlugin({ 9 | matcher: isString, 10 | evaluate({ literal, user }) { 11 | if (!user || literal !== name) return false; 12 | 13 | try { 14 | return fn(user) ?? false; 15 | } catch { 16 | return false; 17 | } 18 | }, 19 | }); 20 | }; 21 | -------------------------------------------------------------------------------- /.github/workflows/test-pr.yaml: -------------------------------------------------------------------------------- 1 | name: Test PR 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | jobs: 11 | build: 12 | name: Build 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout Repo 16 | uses: actions/checkout@v2 17 | 18 | - name: Setup Node.js 19 | uses: actions/setup-node@v2 20 | with: 21 | node-version: 18 22 | 23 | - name: Install Dependencies 24 | run: npm ci 25 | 26 | - name: Build 27 | run: npx turbo run lint build 28 | -------------------------------------------------------------------------------- /packages/keat/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "src", 4 | "outDir": "lib", 5 | "lib": ["ESNext", "DOM"], 6 | "module": "ESNext", 7 | "target": "ESNext", 8 | "moduleResolution": "node", 9 | "allowSyntheticDefaultImports": true, 10 | "declaration": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "esModuleInterop": true, 13 | "pretty": true, 14 | "resolveJsonModule": true, 15 | "skipLibCheck": true, 16 | "removeComments": false, 17 | "strict": true 18 | }, 19 | "exclude": [ 20 | "node_modules", 21 | "lib" 22 | ], 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /packages/keat-posthog/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "src", 4 | "outDir": "lib", 5 | "lib": ["ESNext", "DOM"], 6 | "module": "ESNext", 7 | "target": "ESNext", 8 | "moduleResolution": "node", 9 | "allowSyntheticDefaultImports": true, 10 | "declaration": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "esModuleInterop": true, 13 | "pretty": true, 14 | "resolveJsonModule": true, 15 | "skipLibCheck": true, 16 | "removeComments": false, 17 | "strict": true 18 | }, 19 | "exclude": [ 20 | "node_modules", 21 | "lib" 22 | ], 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /packages/keat-launchdarkly/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "src", 4 | "outDir": "lib", 5 | "lib": ["ESNext", "DOM"], 6 | "module": "ESNext", 7 | "target": "ESNext", 8 | "moduleResolution": "node", 9 | "allowSyntheticDefaultImports": true, 10 | "declaration": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "esModuleInterop": true, 13 | "pretty": true, 14 | "resolveJsonModule": true, 15 | "skipLibCheck": true, 16 | "removeComments": false, 17 | "strict": true 18 | }, 19 | "exclude": [ 20 | "node_modules", 21 | "lib" 22 | ], 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /packages/keat-react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "src", 4 | "outDir": "lib", 5 | "lib": ["ESNext", "DOM"], 6 | "module": "ESNext", 7 | "target": "ESNext", 8 | "moduleResolution": "node", 9 | "allowSyntheticDefaultImports": true, 10 | "declaration": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "esModuleInterop": true, 13 | "jsx": "react", 14 | "pretty": true, 15 | "resolveJsonModule": true, 16 | "skipLibCheck": true, 17 | "removeComments": false, 18 | "strict": true 19 | }, 20 | "exclude": [ 21 | "node_modules", 22 | "lib" 23 | ], 24 | "include": [ 25 | "src" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /examples/react-vite/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /packages/keat/src/plugins/timeInterval.ts: -------------------------------------------------------------------------------- 1 | import {createPlugin} from "../plugin"; 2 | import {isString} from "../matchers"; 3 | 4 | export const timeInterval = () => { 5 | return createPlugin({ 6 | matcher: isString, 7 | evaluate({ literal }) { 8 | const split = literal.split("/"); 9 | if (split.length !== 2) return false; 10 | const now = Date.now(); 11 | const start = Date.parse(split[0]); 12 | const end = Date.parse(split[1]); 13 | const isStart = !isNaN(start); 14 | const isEnd = !isNaN(end); 15 | 16 | if (isStart && isEnd) { 17 | // ISO-8601 time intervals with start and end. 18 | return start < now && now < end; 19 | } else { 20 | return false; 21 | } 22 | }, 23 | }); 24 | }; 25 | -------------------------------------------------------------------------------- /packages/keat/src/plugins/localConfig.ts: -------------------------------------------------------------------------------- 1 | import { createPlugin } from "../plugin"; 2 | import {Config, Rule} from "../types"; 3 | 4 | export const localConfig = (config: Config) => { 5 | return createPlugin({ 6 | onPluginInit: async (_ctx, { setConfig }) => { 7 | setConfig(config); 8 | }, 9 | matcher: (literal) => literal, 10 | evaluate: () => false, 11 | }); 12 | }; 13 | 14 | export function fromEnv(value?: string): Rule | undefined { 15 | if (!value) return undefined; 16 | const data = value 17 | .split(",") 18 | .map((v) => v.trim()) 19 | .map((v) => { 20 | if (v === "true") return true; 21 | const parsed = parseInt(v); 22 | return isNaN(parsed) ? v : parsed; 23 | }); 24 | if (data.length === 1) return data[0]; 25 | return { OR: data }; 26 | } 27 | -------------------------------------------------------------------------------- /examples/react-vite/src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | text-align: center; 6 | } 7 | 8 | .logo { 9 | height: 6em; 10 | padding: 1.5em; 11 | will-change: filter; 12 | transition: filter 300ms; 13 | } 14 | .logo:hover { 15 | filter: drop-shadow(0 0 2em #646cffaa); 16 | } 17 | .logo.react:hover { 18 | filter: drop-shadow(0 0 2em #61dafbaa); 19 | } 20 | 21 | @keyframes logo-spin { 22 | from { 23 | transform: rotate(0deg); 24 | } 25 | to { 26 | transform: rotate(360deg); 27 | } 28 | } 29 | 30 | @media (prefers-reduced-motion: no-preference) { 31 | a:nth-of-type(2) .logo { 32 | animation: logo-spin infinite 20s linear; 33 | } 34 | } 35 | 36 | .card { 37 | padding: 2em; 38 | } 39 | 40 | .read-the-docs { 41 | color: #888; 42 | } 43 | -------------------------------------------------------------------------------- /packages/keat/src/matchers.ts: -------------------------------------------------------------------------------- 1 | export type Matcher = (literal: unknown) => T | null; 2 | 3 | export const isNone: Matcher = () => { 4 | return null; 5 | }; 6 | 7 | export const isAny: Matcher = (literal) => { 8 | return literal; 9 | }; 10 | 11 | export const isBoolean: Matcher = (literal) => { 12 | return typeof literal === "boolean" ? literal : null; 13 | }; 14 | 15 | export const isString: Matcher = (literal) => { 16 | return typeof literal === "string" ? literal : null; 17 | }; 18 | 19 | export const isNumber: Matcher = (literal) => { 20 | return typeof literal === "number" ? literal : null; 21 | }; 22 | 23 | export const isDate: Matcher = (literal) => { 24 | const date = typeof literal === "string" ? Date.parse(literal) : NaN; 25 | return isNaN(date) ? new Date(date) : null; 26 | }; 27 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | jobs: 11 | release: 12 | name: Release 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout Repo 16 | uses: actions/checkout@v2 17 | 18 | - name: Setup Node.js 19 | uses: actions/setup-node@v2 20 | with: 21 | node-version: 18 22 | 23 | - name: Install Dependencies 24 | run: npm ci 25 | 26 | - name: Create Release Pull Request or Publish to npm 27 | id: changesets 28 | uses: changesets/action@v1 29 | with: 30 | commit: "chore: release" 31 | publish: npm run publish-packages 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 35 | -------------------------------------------------------------------------------- /examples/react-vite/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-vite", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "react": "^18.2.0", 14 | "react-dom": "^18.2.0", 15 | "keat-react": "*" 16 | }, 17 | "devDependencies": { 18 | "@types/react": "^18.2.15", 19 | "@types/react-dom": "^18.2.7", 20 | "@typescript-eslint/eslint-plugin": "^6.0.0", 21 | "@typescript-eslint/parser": "^6.0.0", 22 | "@vitejs/plugin-react": "^4.0.3", 23 | "eslint": "^8.45.0", 24 | "eslint-plugin-react-hooks": "^4.6.0", 25 | "eslint-plugin-react-refresh": "^0.4.3", 26 | "typescript": "^5.0.2", 27 | "vite": "^4.4.5" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/keat/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "keat", 3 | "version": "0.9.0", 4 | "type": "module", 5 | "source": "src/index.ts", 6 | "module": "lib/index.js", 7 | "typings": "lib/index.d.ts", 8 | "types": "lib/index.d.ts", 9 | "license": "MIT", 10 | "sideEffects": false, 11 | "author": { 12 | "name": "Wito Delnat", 13 | "url": "https://github.com/WitoDelnat" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/WitoDelnat/keat" 18 | }, 19 | "keywords": [ 20 | "keat", 21 | "feature flags", 22 | "feature toggle", 23 | "feature management", 24 | "continuous delivery", 25 | "release" 26 | ], 27 | "files": [ 28 | "lib" 29 | ], 30 | "scripts": { 31 | "lint": "tsc --noEmit", 32 | "build": "rimraf lib && tsc" 33 | }, 34 | "devDependencies": { 35 | "rimraf": "3.0.2", 36 | "tsconfig": "*", 37 | "typescript": "4.9.5" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/keat/src/plugins/queryParam.ts: -------------------------------------------------------------------------------- 1 | import {createPlugin} from "../plugin"; 2 | import {isString} from "../matchers"; 3 | 4 | type Options = { 5 | /** 6 | * The key of the URL's query parameter. 7 | * 8 | * Defaults to the `name`. 9 | */ 10 | key?: string; 11 | 12 | /** 13 | * The value that belongs to the key of the URL's query parameter. 14 | * 15 | * Defaults to a `has` check. 16 | */ 17 | value?: string; 18 | }; 19 | 20 | export const queryParam = (name: string, { key, value }: Options = {}) => { 21 | return createPlugin({ 22 | matcher: isString, 23 | evaluate({ literal }) { 24 | if (literal !== name || typeof window === "undefined") return false; 25 | 26 | const queryString = window.location.search; 27 | const params = new URLSearchParams(queryString); 28 | 29 | return value 30 | ? params.get(key ?? name) === value 31 | : params.has(key ?? name); 32 | }, 33 | }); 34 | }; 35 | -------------------------------------------------------------------------------- /packages/keat/src/plugins/customConfig.ts: -------------------------------------------------------------------------------- 1 | import {createPlugin} from "../plugin"; 2 | import {Config} from "../types"; 3 | import {isNone} from "../matchers"; 4 | 5 | type CustomConfigPluginOptions = { 6 | fetch: () => Promise; 7 | retries?: number; 8 | }; 9 | 10 | const DEFAULT_OPTIONS = { 11 | retries: 3, 12 | }; 13 | 14 | export const customConfig = (rawOptions: CustomConfigPluginOptions) => { 15 | const options = { ...DEFAULT_OPTIONS, ...rawOptions }; 16 | 17 | return createPlugin({ 18 | onPluginInit: async (_ctx, { setConfig }) => { 19 | let timeout = 50; 20 | for (let i = 0; i < options.retries; i++) { 21 | try { 22 | const remoteConfig = await options.fetch(); 23 | setConfig(remoteConfig); 24 | break; 25 | } catch (err) { 26 | timeout = timeout * 2; 27 | await new Promise((r) => setTimeout(r, timeout)); 28 | } 29 | } 30 | }, 31 | matcher: isNone, 32 | }); 33 | }; 34 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Wito Delnat 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /packages/keat/src/plugins/keatRelease.ts: -------------------------------------------------------------------------------- 1 | import { createPlugin } from '../plugin' 2 | import { isNone } from '../matchers' 3 | 4 | export const keatRelease = (appId: string) => { 5 | const fetchConfig = async (url: string) => { 6 | let timeout = 100 7 | for (let i = 0; i < 3; i++) { 8 | try { 9 | const response = await fetch(url) 10 | 11 | if (!response.ok) throw new Error('fetch failed') 12 | 13 | return await response.json() 14 | } catch (err) { 15 | timeout = timeout * 2 16 | await pause(timeout) 17 | } 18 | } 19 | } 20 | 21 | return createPlugin({ 22 | onPluginInit: async (_ctx, { setConfig }) => { 23 | const url = `https://sync.keat.app/${appId}/flags` 24 | const remoteConfig = await fetchConfig(url) 25 | setConfig(remoteConfig) 26 | }, 27 | matcher: isNone, 28 | }) 29 | } 30 | 31 | function pause(ms: number): Promise { 32 | return new Promise((resolve) => setTimeout(resolve, ms)); 33 | } 34 | -------------------------------------------------------------------------------- /packages/keat-posthog/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "keat-posthig", 3 | "version": "0.1.0", 4 | "type": "module", 5 | "source": "src/index.ts", 6 | "module": "lib/index.js", 7 | "typings": "lib/index.d.ts", 8 | "types": "lib/index.d.ts", 9 | "license": "MIT", 10 | "sideEffects": false, 11 | "author": { 12 | "name": "Wito Delnat", 13 | "url": "https://github.com/WitoDelnat" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/WitoDelnat/keat" 18 | }, 19 | "keywords": [ 20 | "keat", 21 | "feature flags", 22 | "feature toggle", 23 | "feature management", 24 | "continuous delivery", 25 | "release", 26 | "PostHog" 27 | ], 28 | "files": [ 29 | "lib" 30 | ], 31 | "scripts": { 32 | "lint": "tsc --noEmit", 33 | "build": "rimraf lib && tsc" 34 | }, 35 | "dependencies": { 36 | "keat": "*" 37 | }, 38 | "devDependencies": { 39 | "posthog-js": "1.50.2", 40 | "rimraf": "3.0.2", 41 | "tsconfig": "*", 42 | "typescript": "4.9.5" 43 | }, 44 | "peerDependencies": { 45 | "posthog-js": "^1.25" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/keat/src/plugins/schedule.ts: -------------------------------------------------------------------------------- 1 | import {createPlugin} from "../plugin"; 2 | import {isString} from "../matchers"; 3 | 4 | const DAYS = [ 5 | "sunday", 6 | "monday", 7 | "tuesday", 8 | "wednesday", 9 | "thursday", 10 | "friday", 11 | "saturday", 12 | ] as const; 13 | 14 | type Day = (typeof DAYS)[number]; 15 | type Period = { from: number; to: number }; 16 | type Schedule = Partial>; 17 | 18 | export const businessHours = ( 19 | name: string, 20 | schedule: Schedule = DEFAULT_SCHEDULE 21 | ) => { 22 | return createPlugin({ 23 | matcher: isString, 24 | evaluate({ literal }) { 25 | if (literal !== name) return false; 26 | const now = new Date(); 27 | const hour = new Date().getHours(); 28 | const periods = schedule[DAYS[now.getDay()]] ?? []; 29 | return periods.some((p) => p.from <= hour && hour >= p.to); 30 | }, 31 | }); 32 | }; 33 | 34 | const DEFAULT_SCHEDULE: Schedule = { 35 | monday: [{ from: 9, to: 5 }], 36 | tuesday: [{ from: 9, to: 5 }], 37 | wednesday: [{ from: 9, to: 5 }], 38 | thursday: [{ from: 9, to: 5 }], 39 | friday: [{ from: 9, to: 5 }], 40 | }; 41 | -------------------------------------------------------------------------------- /packages/keat-launchdarkly/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "keat-launchdarkly", 3 | "version": "0.1.0", 4 | "type": "module", 5 | "source": "src/index.ts", 6 | "module": "lib/index.js", 7 | "typings": "lib/index.d.ts", 8 | "types": "lib/index.d.ts", 9 | "license": "MIT", 10 | "sideEffects": false, 11 | "author": { 12 | "name": "Wito Delnat", 13 | "url": "https://github.com/WitoDelnat" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/WitoDelnat/keat" 18 | }, 19 | "keywords": [ 20 | "keat", 21 | "feature flags", 22 | "feature toggle", 23 | "feature management", 24 | "continuous delivery", 25 | "release", 26 | "LaunchDarkly" 27 | ], 28 | "files": [ 29 | "lib" 30 | ], 31 | "scripts": { 32 | "lint": "tsc --noEmit", 33 | "build": "rimraf lib && tsc" 34 | }, 35 | "dependencies": { 36 | "keat": "*" 37 | }, 38 | "devDependencies": { 39 | "launchdarkly-js-client-sdk": "3.1.0", 40 | "rimraf": "3.0.2", 41 | "tsconfig": "*", 42 | "typescript": "4.9.5" 43 | }, 44 | "peerDependencies": { 45 | "launchdarkly-js-client-sdk": "^3.0" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/keat-react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "keat-react", 3 | "version": "0.1.0", 4 | "type": "module", 5 | "source": "src/index.ts", 6 | "module": "lib/index.js", 7 | "typings": "lib/index.d.ts", 8 | "types": "lib/index.d.ts", 9 | "license": "MIT", 10 | "sideEffects": false, 11 | "author": { 12 | "name": "Wito Delnat", 13 | "url": "https://github.com/WitoDelnat" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/WitoDelnat/keat" 18 | }, 19 | "keywords": [ 20 | "keat", 21 | "feature flags", 22 | "feature toggle", 23 | "feature management", 24 | "continuous delivery", 25 | "release" 26 | ], 27 | "files": [ 28 | "lib" 29 | ], 30 | "scripts": { 31 | "lint": "tsc --noEmit", 32 | "build": "rimraf lib && tsc" 33 | }, 34 | "dependencies": { 35 | "keat": "*" 36 | }, 37 | "devDependencies": { 38 | "@types/react": "18.0.9", 39 | "react": "18.1.0", 40 | "react-dom": "18.1.0", 41 | "rimraf": "3.0.2", 42 | "tsconfig": "*", 43 | "typescript": "4.9.5" 44 | }, 45 | "peerDependencies": { 46 | "react": "^18.0 || ^17.0 || ^16.0", 47 | "react-dom": "^18.0 || ^17.0 || ^16.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/keat-posthog/src/posthog.ts: -------------------------------------------------------------------------------- 1 | import posthogUpstream, { PostHog, PostHogConfig } from "posthog-js"; 2 | import {createPlugin, flagsToConfig, isAny} from "keat"; 3 | 4 | export const posthog = ( 5 | apiTokenOrClient: string | PostHog, 6 | options?: Partial 7 | ) => { 8 | let client: PostHog; 9 | 10 | return createPlugin({ 11 | onPluginInit: async ({ variates }, { setConfig, onChange }) => { 12 | return new Promise((r) => { 13 | if (typeof apiTokenOrClient === "string") { 14 | client = posthogUpstream; 15 | client.init(apiTokenOrClient, { 16 | loaded: () => { 17 | r(); 18 | }, 19 | ...options, 20 | }); 21 | } else { 22 | client = apiTokenOrClient; 23 | } 24 | 25 | client.onFeatureFlags((_, flags) => { 26 | const config = flagsToConfig(flags, variates); 27 | setConfig(config); 28 | onChange(); 29 | }); 30 | }); 31 | }, 32 | async onIdentify({ user }) { 33 | const id = user?.id ?? user?.sub ?? user?.email; 34 | client.identify(id, user); 35 | }, 36 | matcher: isAny, 37 | evaluate({ feature, variate }) { 38 | return variate === client.getFeatureFlag(feature); 39 | }, 40 | }); 41 | }; 42 | -------------------------------------------------------------------------------- /examples/react-vite/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | parserOptions: { 18 | ecmaVersion: 'latest', 19 | sourceType: 'module', 20 | project: ['./tsconfig.json', './tsconfig.node.json'], 21 | tsconfigRootDir: __dirname, 22 | }, 23 | ``` 24 | 25 | - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` 26 | - Optionally add `plugin:@typescript-eslint/stylistic-type-checked` 27 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list 28 | -------------------------------------------------------------------------------- /packages/keat/src/plugins/cache.ts: -------------------------------------------------------------------------------- 1 | import {User} from "../types"; 2 | import {DEFAULT_GET_USER_ID} from "../utils"; 3 | import {createPlugin} from "../plugin"; 4 | import {isAny} from "../matchers"; 5 | 6 | type CachePluginOptions = { 7 | createCacheKey?: CacheFn; 8 | }; 9 | 10 | type CacheFn = (configId: number, feature: string, user?: User) => string; 11 | 12 | const DEFAULT_CREATE_CACHE_KEY: CacheFn = (configId, feature, user) => { 13 | const userId = DEFAULT_GET_USER_ID(user); 14 | return `${configId}-${feature}-${userId}`; 15 | }; 16 | 17 | export const cache = (options?: CachePluginOptions) => { 18 | const fallbackCache = new Map(); 19 | const latestCache = new Map(); 20 | const cacheFn = options?.createCacheKey ?? DEFAULT_CREATE_CACHE_KEY; 21 | let lastConfigId = -1; 22 | 23 | let key = ""; 24 | let cache = fallbackCache; 25 | 26 | return createPlugin({ 27 | onPostEvaluate({ result }) { 28 | cache.set(key, result); 29 | }, 30 | 31 | onPreEvaluate({ configId, feature, user }) { 32 | key = cacheFn(configId, feature, user); 33 | cache = configId === 0 ? fallbackCache : latestCache; 34 | if (configId !== 0 && lastConfigId !== configId) { 35 | cache.clear(); 36 | } 37 | }, 38 | matcher: isAny, 39 | evaluate({ variate }) { 40 | const cachedResult = cache.get(key); 41 | return variate === cachedResult; 42 | }, 43 | }); 44 | }; 45 | -------------------------------------------------------------------------------- /packages/keat-posthog/src/posthog.js: -------------------------------------------------------------------------------- 1 | import posthogUpstream from "posthog-js"; 2 | import { createPlugin, flagsToConfig, isAny } from "keat"; 3 | export const posthog = (apiTokenOrClient, options) => { 4 | let client; 5 | return createPlugin({ 6 | onPluginInit: async ({ variates }, { setConfig, onChange }) => { 7 | return new Promise((r) => { 8 | if (typeof apiTokenOrClient === "string") { 9 | client = posthogUpstream; 10 | client.init(apiTokenOrClient, { 11 | loaded: () => { 12 | r(); 13 | }, 14 | ...options, 15 | }); 16 | } 17 | else { 18 | client = apiTokenOrClient; 19 | } 20 | client.onFeatureFlags((_, flags) => { 21 | const config = flagsToConfig(flags, variates); 22 | setConfig(config); 23 | onChange(); 24 | }); 25 | }); 26 | }, 27 | async onIdentify({ user }) { 28 | const id = user?.id ?? user?.sub ?? user?.email; 29 | client.identify(id, user); 30 | }, 31 | matcher: isAny, 32 | evaluate({ feature, variate }) { 33 | return variate === client.getFeatureFlag(feature); 34 | }, 35 | }); 36 | }; 37 | -------------------------------------------------------------------------------- /examples/react-vite/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/keat/src/display.ts: -------------------------------------------------------------------------------- 1 | import { Display } from "./types"; 2 | 3 | export function load(init: PromiseLike) { 4 | const delayLong = pause(3000); 5 | const delay = pause(100); 6 | 7 | const loading: Record> = { 8 | block: Promise.race([init, delayLong]), 9 | fallback: Promise.race([init, delay]), 10 | optional: Promise.race([init, delay]), 11 | swap: Promise.resolve(), 12 | }; 13 | const result: Record = { 14 | block: undefined, 15 | fallback: undefined, 16 | optional: undefined, 17 | swap: undefined, 18 | }; 19 | 20 | // block 21 | delayLong.then(() => (result["block"] = result["block"] ?? false)); 22 | init.then(() => (result["block"] = true)); 23 | 24 | // swap 25 | result["swap"] = false; 26 | init.then(() => (result["swap"] = true)); 27 | 28 | // fallback 29 | let canSwap = true; 30 | delayLong.then(() => (canSwap = false)); 31 | delay.then(() => (result["fallback"] = result["fallback"] ?? false)); 32 | init.then(() => (result["fallback"] = canSwap)); 33 | 34 | // optional 35 | init.then(() => (result["optional"] = result["optional"] === undefined)); 36 | delay.then(() => (result["optional"] = result["optional"] !== undefined)); 37 | 38 | return { 39 | ready: (display: Display): Promise => loading[display], 40 | useLatest: (display: Display): boolean => result[display] ?? false, 41 | }; 42 | } 43 | 44 | function pause(ms: number): Promise { 45 | return new Promise((resolve) => setTimeout(resolve, ms)); 46 | } 47 | -------------------------------------------------------------------------------- /packages/keat-launchdarkly/src/launchdarkly.ts: -------------------------------------------------------------------------------- 1 | import { initialize, LDClient, LDOptions } from "launchdarkly-js-client-sdk"; 2 | import {createPlugin, flagsToConfig, isNone} from "keat"; 3 | 4 | export const launchDarkly = (clientId: string, options?: LDOptions) => { 5 | let client: LDClient; 6 | 7 | return createPlugin({ 8 | onPluginInit: async ({ variates }, { setConfig, onChange }) => { 9 | client = initialize(clientId, { kind: "user", anonymous: true }, options); 10 | 11 | return new Promise((r) => { 12 | function cleanup() { 13 | client.off("ready", handleReady); 14 | client.off("failed", handleFailure); 15 | } 16 | function handleFailure() { 17 | cleanup(); 18 | r(); 19 | } 20 | function handleReady() { 21 | cleanup(); 22 | const flags = client.allFlags(); 23 | const config = flagsToConfig(flags, variates); 24 | setConfig(config); 25 | r(); 26 | } 27 | client.on("failed", handleFailure); 28 | client.on("ready", () => { 29 | handleReady(); 30 | }); 31 | client.on("change", () => { 32 | const flags = client.allFlags(); 33 | const config = flagsToConfig(flags, variates); 34 | setConfig(config); 35 | onChange(); 36 | }); 37 | }); 38 | }, 39 | async onIdentify({ user }) { 40 | await client.identify({ 41 | kind: "user", 42 | key: user?.id ?? user?.sub ?? user?.email, 43 | ...user, 44 | }); 45 | }, 46 | matcher: isNone, 47 | }); 48 | }; 49 | -------------------------------------------------------------------------------- /packages/keat/src/plugins/anonymous.ts: -------------------------------------------------------------------------------- 1 | import {User} from "../types"; 2 | import {DEFAULT_CREATE_USER} from "../utils"; 3 | import {createPlugin} from "../plugin"; 4 | import {isNone} from "../matchers"; 5 | 6 | type AnonymousPluginOptions = { 7 | createUser?: (id: string) => User; 8 | createId?: () => string; 9 | persist?: boolean; 10 | }; 11 | 12 | const DEFAULT_CREATE_ID = () => { 13 | const id = hasCrypto() ? globalThis.crypto.randomUUID() : undefined; 14 | return id ?? Math.floor(Math.random() * 100000000).toString(); 15 | }; 16 | 17 | export const anonymous = (options?: AnonymousPluginOptions) => { 18 | const createId = options?.createId ?? DEFAULT_CREATE_ID; 19 | const createUser = options?.createUser ?? DEFAULT_CREATE_USER; 20 | let anonymousUser: unknown; 21 | 22 | return createPlugin({ 23 | onPluginInit() { 24 | let anonymousId; 25 | 26 | if (options?.persist && hasLocalStorage()) { 27 | anonymousId = localStorage.getItem("__keat_aid"); 28 | if (!anonymousId) { 29 | anonymousId = createId(); 30 | localStorage.setItem("__keat_aid", anonymousId); 31 | } 32 | } else { 33 | anonymousId = createId(); 34 | } 35 | 36 | anonymousUser = createUser(anonymousId); 37 | }, 38 | onPreEvaluate({ user }, { setUser }) { 39 | if (!user) { 40 | setUser(anonymousUser); 41 | } 42 | }, 43 | matcher: isNone, 44 | }); 45 | }; 46 | 47 | function hasLocalStorage() { 48 | return typeof window !== "undefined" && window.localStorage; 49 | } 50 | 51 | function hasCrypto() { 52 | return typeof globalThis !== "undefined" && globalThis.crypto; 53 | } 54 | -------------------------------------------------------------------------------- /examples/react-vite/src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | -webkit-text-size-adjust: 100%; 15 | } 16 | 17 | a { 18 | font-weight: 500; 19 | color: #646cff; 20 | text-decoration: inherit; 21 | } 22 | a:hover { 23 | color: #535bf2; 24 | } 25 | 26 | body { 27 | margin: 0; 28 | display: flex; 29 | place-items: center; 30 | min-width: 320px; 31 | min-height: 100vh; 32 | } 33 | 34 | h1 { 35 | font-size: 3.2em; 36 | line-height: 1.1; 37 | } 38 | 39 | button { 40 | border-radius: 8px; 41 | border: 1px solid transparent; 42 | padding: 0.6em 1.2em; 43 | font-size: 1em; 44 | font-weight: 500; 45 | font-family: inherit; 46 | background-color: #1a1a1a; 47 | cursor: pointer; 48 | transition: border-color 0.25s; 49 | } 50 | button:hover { 51 | border-color: #646cff; 52 | } 53 | button:focus, 54 | button:focus-visible { 55 | outline: 4px auto -webkit-focus-ring-color; 56 | } 57 | 58 | .description { 59 | margin: 0 auto 12px; 60 | text-align: center; 61 | max-width: 420px; 62 | } 63 | 64 | .actions { 65 | display: flex; 66 | gap: 4px; 67 | } 68 | 69 | @media (prefers-color-scheme: light) { 70 | :root { 71 | color: #213547; 72 | background-color: #ffffff; 73 | } 74 | a:hover { 75 | color: #747bff; 76 | } 77 | button { 78 | background-color: #f9f9f9; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [1.0.0-rc.8](https://github.com/WitoDelnat/keat/compare/v1.0.0-rc.7...v1.0.0-rc.8) (2023-05-23) 4 | 5 | 6 | ### Bug Fixes 7 | 8 | * fix build ([e8f3604](https://github.com/WitoDelnat/keat/commit/e8f360484879cf55028ee169fa3eb39eb9da1405)) 9 | 10 | 11 | ### Miscellaneous Chores 12 | 13 | * release rc 8 ([ec91e47](https://github.com/WitoDelnat/keat/commit/ec91e4725c016374223659a2fe5ceb037ab9a91b)) 14 | 15 | ## [1.0.0-rc.7](https://github.com/WitoDelnat/keat/compare/v1.0.0-rc.5...v1.0.0-rc.7) (2023-04-22) 16 | 17 | 18 | ### Features 19 | 20 | * add change to localStorage plugin ([a264145](https://github.com/WitoDelnat/keat/commit/a264145b0d6e5d0c98bf58efc39994ab60aca06a)) 21 | * add LaunchDarkly integration ([acfb6e7](https://github.com/WitoDelnat/keat/commit/acfb6e79800c16467b53122fb20eaf9ba66201fa)) 22 | * add LaunchDarkly integration ([8c4f441](https://github.com/WitoDelnat/keat/commit/8c4f441cb3f479820c570ca94f8c39d05ae9f493)) 23 | * add launchDarkly test ([3b923f1](https://github.com/WitoDelnat/keat/commit/3b923f1ecf0c809e9899c4a4a600578d50c0306e)) 24 | * add posthog integration ([b7a1299](https://github.com/WitoDelnat/keat/commit/b7a12993362768ce0f368cc3fdd8ae62a93cac72)) 25 | * add storybook ([c7081aa](https://github.com/WitoDelnat/keat/commit/c7081aa3109bc8e23e830a19f7a2748e507f15e7)) 26 | * improve plugin API ([13ce909](https://github.com/WitoDelnat/keat/commit/13ce9099a76c01077ed14b8aad9c006734642931)) 27 | 28 | 29 | ### Bug Fixes 30 | 31 | * fix build ([75f7777](https://github.com/WitoDelnat/keat/commit/75f777717a784793a5719f3dd35b5f4ea69a8776)) 32 | 33 | 34 | ### Miscellaneous Chores 35 | 36 | * release rc 7 ([1fb270f](https://github.com/WitoDelnat/keat/commit/1fb270fe4877eb1f999dc7bfc2888faf243a476a)) 37 | -------------------------------------------------------------------------------- /packages/keat/src/plugins/localStorage.ts: -------------------------------------------------------------------------------- 1 | import {createNopPlugin, createPlugin} from "../plugin"; 2 | import {isString} from "../matchers"; 3 | 4 | 5 | type Options = { 6 | /** 7 | * The key of the local storage entry. 8 | * 9 | * Defaults to the `name`. 10 | */ 11 | key?: string; 12 | 13 | /** 14 | * The value that's expected for the local storage entry. 15 | * 16 | * Defaults to a `has` check. 17 | */ 18 | value?: string; 19 | 20 | /** 21 | * Whether the local storage is polled for updated. 22 | * 23 | * The default polling time every 2 seconds. 24 | * You can change it by setting a number (in ms). 25 | */ 26 | poll?: boolean | number; 27 | }; 28 | 29 | export const localStorage = ( 30 | name: string, 31 | { key, value, poll = false }: Options = {} 32 | ) => { 33 | const hasLocalStorage = typeof window !== "undefined" && window.localStorage; 34 | const hasSetInterval = typeof window !== "undefined" && window.setInterval; 35 | if (!hasLocalStorage || !hasSetInterval) return createNopPlugin(); 36 | 37 | const pollInterval = 38 | poll === true ? 2000 : typeof poll === "number" && poll > 0 ? poll : 0; 39 | const k = key ?? name; 40 | let item: any; 41 | 42 | return createPlugin({ 43 | onPluginInit(_, { onChange }) { 44 | item = window.localStorage.getItem(k); 45 | if (pollInterval > 0) { 46 | setInterval(() => { 47 | const newItem = window.localStorage.getItem(k); 48 | const hasChanged = item !== newItem; 49 | item = newItem; 50 | if (hasChanged) onChange(); 51 | }, pollInterval); 52 | } 53 | }, 54 | matcher: isString, 55 | evaluate({ literal }) { 56 | if (literal !== name) return false; 57 | return value ? item === value : Boolean(item); 58 | }, 59 | }); 60 | }; 61 | -------------------------------------------------------------------------------- /packages/keat/src/plugins/remoteConfig.ts: -------------------------------------------------------------------------------- 1 | import { createPlugin } from "../plugin"; 2 | import {Config} from "../types"; 3 | 4 | type RemoteConfigPluginOptions = { 5 | interval?: number; 6 | retries?: number; 7 | }; 8 | 9 | const DEFAULT_OPTIONS = { 10 | retries: 3, 11 | }; 12 | 13 | export const remoteConfig = ( 14 | url: string, 15 | rawOptions?: RemoteConfigPluginOptions 16 | ) => { 17 | const options = { ...DEFAULT_OPTIONS, ...rawOptions }; 18 | 19 | const fetchConfig = async (url: string) => { 20 | let timeout = 100; 21 | for (let i = 0; i < options.retries; i++) { 22 | try { 23 | const response = await fetch(url); 24 | 25 | if (!response.ok) throw new Error("fetch failed"); 26 | 27 | const remoteConfig = await response.json(); 28 | return remoteConfig; 29 | } catch (err) { 30 | timeout = timeout * 2; 31 | await pause(timeout); 32 | } 33 | } 34 | }; 35 | 36 | const backgroundTask = async ( 37 | interval: number, 38 | setConfig: (newConfig: Config) => void 39 | ) => { 40 | try { 41 | while (true) { 42 | await pause(interval * 1000); 43 | const remoteConfig = await fetchConfig(url); 44 | setConfig(remoteConfig); 45 | } 46 | } catch { 47 | return; 48 | } 49 | }; 50 | 51 | return createPlugin({ 52 | onPluginInit: async (_ctx, { setConfig }) => { 53 | const remoteConfig = await fetchConfig(url); 54 | setConfig(remoteConfig); 55 | 56 | if (options.interval !== undefined) { 57 | backgroundTask(options.interval, setConfig); 58 | } 59 | }, 60 | matcher: (literal) => literal, 61 | evaluate: () => false, 62 | }); 63 | }; 64 | 65 | function pause(ms: number): Promise { 66 | return new Promise((resolve) => setTimeout(resolve, ms)); 67 | } 68 | -------------------------------------------------------------------------------- /packages/keat-react/src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import {AnyFeatures, Display, KeatApi, keatCore, KeatInit} from "keat"; 3 | 4 | export * from "keat"; 5 | 6 | type KeatReactApi = KeatApi & { 7 | useKeat(display?: Display): { 8 | loading: boolean; 9 | variation: KeatApi["variation"]; 10 | setUser: KeatApi["identify"]; 11 | }; 12 | useVariation(display?: Display): KeatApi["variation"]; 13 | FeatureBoundary(args: { 14 | name: TFeature; 15 | invisible?: React.ReactNode; 16 | children?: React.ReactNode; 17 | fallback?: React.ReactNode; 18 | display?: Display; 19 | }): JSX.Element; 20 | }; 21 | 22 | export function keat( 23 | init: KeatInit 24 | ): KeatReactApi { 25 | const keatInstance = keatCore(init); 26 | 27 | return { 28 | ...keatInstance, 29 | useKeat(display) { 30 | const [loading, setLoading] = React.useState(true); 31 | 32 | React.useEffect(() => { 33 | keatInstance.ready(display).then(() => setLoading(false)); 34 | }, [setLoading]); 35 | 36 | return { 37 | loading, 38 | variation: keatInstance.variation, 39 | setUser: keatInstance.identify, 40 | }; 41 | }, 42 | useVariation() { 43 | return keatInstance.variation; 44 | }, 45 | FeatureBoundary({ 46 | display, 47 | name, 48 | invisible = null, 49 | fallback = null, 50 | children, 51 | }) { 52 | const [loading, setLoading] = React.useState(true); 53 | const [_, forceUpdate] = React.useReducer((x) => x + 1, 0); 54 | 55 | React.useEffect(() => { 56 | keatInstance.ready(display).then(() => setLoading(false)); 57 | }, [setLoading]); 58 | 59 | React.useEffect(() => { 60 | const unsubscribe = keatInstance.onChange(forceUpdate); 61 | return () => unsubscribe(); 62 | }, []); 63 | 64 | if (loading) { 65 | return <>{invisible}; 66 | } 67 | 68 | return keatInstance.variation(name, undefined, display) ? ( 69 | <>{children} 70 | ) : ( 71 | <>{fallback} 72 | ); 73 | }, 74 | }; 75 | } 76 | -------------------------------------------------------------------------------- /packages/keat/src/types.ts: -------------------------------------------------------------------------------- 1 | import { Listener, Unsubscribe } from "./keat"; 2 | import type { Plugin } from "./plugin"; 3 | 4 | /** 5 | * Bring your own user with declaration merging: 6 | * 7 | * @example 8 | * ``` 9 | * declare module 'keat' { 10 | * interface CustomTypes { 11 | * user: { name: string, email: string, developerPreview: boolean } 12 | * } 13 | * } 14 | * ``` 15 | */ 16 | export interface CustomTypes { 17 | // user: ... 18 | } 19 | 20 | export type User = CustomTypes extends { user: infer T } 21 | ? T 22 | : ({ id: string } | { sub: string } | { email: string }) & 23 | Record; 24 | 25 | /* * * * * * * * * * * * * 26 | * API 27 | * * * * * * * * * * * * */ 28 | export type IdentityFn = (user: User) => string; 29 | export type Display = "block" | "swap" | "fallback" | "optional"; 30 | 31 | export type Literal = boolean | string | number; 32 | export type Rule = { OR: readonly Rule[] } | Literal; 33 | 34 | export type Feature = 35 | | Rule 36 | | { 37 | variates?: readonly [any, any]; 38 | when?: Rule; 39 | } 40 | | { 41 | variates?: readonly [any, any, ...any]; 42 | when?: readonly Rule[]; 43 | }; 44 | 45 | export type Config = Record; 46 | 47 | export type KeatInit = { 48 | features: TFeatures; 49 | plugins?: Plugin[]; 50 | display?: Display; 51 | }; 52 | 53 | export type KeatApi = { 54 | ready(display?: Display): Promise; 55 | identify(user?: User): void; 56 | configure(config: Config): void; 57 | setDisplay(display: Display): void; 58 | variation( 59 | feature: TFeature, 60 | user?: User, 61 | display?: Display 62 | ): TFeatures[TFeature] extends { variates: any } 63 | ? TFeatures[TFeature]["variates"] extends readonly any[] 64 | ? TFeatures[TFeature]["variates"][number] 65 | : boolean 66 | : boolean; 67 | onChange(listener: Listener): Unsubscribe; 68 | }; 69 | 70 | /* * * * * * * * * * * * * 71 | * Internal types 72 | * * * * * * * * * * * * */ 73 | export type NormalizedConfig = Record; 74 | export type NormalizedRule = Array; 75 | export type NormalizedElem = boolean | (string | number)[]; 76 | export type AnyFeatures = Record; 77 | -------------------------------------------------------------------------------- /examples/react-vite/src/App.tsx: -------------------------------------------------------------------------------- 1 | import './App.css' 2 | import { keat } from 'keat-react' 3 | import { audience } from 'keat' 4 | import { useCallback, useState } from 'react' 5 | 6 | const { FeatureBoundary, useKeat } = keat({ 7 | features: { 8 | demo: 'staff', 9 | }, 10 | plugins: [ 11 | audience('staff', (u) => u.email.endsWith('@example.com')) 12 | ], 13 | }) 14 | 15 | type Token = typeof EXAMPLE_ID_TOKEN 16 | 17 | const EXAMPLE_ID_TOKEN = { 18 | iss: 'http://auth.example.com', 19 | sub: '123456', 20 | aud: 'demo-client', 21 | exp: 1311281970, 22 | iat: 1311280970, 23 | email: 'jane@example.com', 24 | } 25 | 26 | const GMAIL_ID_TOKEN: Token = { 27 | iss: 'http://auth.example.com', 28 | sub: 'g_23456', 29 | aud: 'demo-client', 30 | exp: 1311281970, 31 | iat: 1311280970, 32 | email: 'john@gmail.com', 33 | } 34 | 35 | function App() { 36 | const { setUser } = useKeat() 37 | const [token, setToken] = useState() 38 | 39 | const identify = useCallback( 40 | (tkn: Token | undefined) => { 41 | setToken(tkn) 42 | setUser(tkn) 43 | }, 44 | [setUser] 45 | ) 46 | 47 | return ( 48 | <> 49 |

Keat + Vite + React

50 | 51 |
52 | Demo disabled

}> 53 |

Demo enabled

54 |
55 | 56 |

57 | In this basic example, users from example.com have Demo 58 | enabled while others do not. 59 |

60 | 61 |
62 | 69 | 70 | 77 | 78 | 85 |
86 | 87 |

88 | You are currently{' '} 89 | {!token ? 'unauthenticated' : `logged in as ${token.email}`} 90 | . 91 |

92 |
93 | 94 | ) 95 | } 96 | 97 | export default App 98 | -------------------------------------------------------------------------------- /packages/keat/src/plugin.ts: -------------------------------------------------------------------------------- 1 | import { isNone, Matcher } from "./matchers"; 2 | import {Config, Rule, User} from "./types"; 3 | 4 | export type LegacyPlugin = { 5 | /** 6 | * Invoked when a plugin is initialized. 7 | */ 8 | onPluginInit?: OnPluginInitHook; 9 | 10 | /** 11 | * Invoked when a flag is evaluated. 12 | */ 13 | onEval?: onPreEvaluateHook; 14 | }; 15 | 16 | export type OnPluginInitHook = ( 17 | ctx: OnPluginInitCtx, 18 | api: OnPluginInitApi 19 | ) => void | Promise; 20 | 21 | export type OnPluginInitCtx = { 22 | features: string[]; 23 | variates: Record; 24 | config?: Config; 25 | }; 26 | 27 | export type OnPluginInitApi = { 28 | setConfig: (newConfig: Config) => void; 29 | onChange: () => void; 30 | }; 31 | 32 | export type OnPluginIdentifyHook = ( 33 | ctx: OnPluginIdentifyCtx 34 | ) => void | Promise; 35 | 36 | export type OnPluginIdentifyCtx = { 37 | user?: User; 38 | }; 39 | 40 | export type onPreEvaluateHook = (ctx: EvalCtx, api: OnEvalApi) => void; 41 | 42 | export type EvalCtx = { 43 | feature: string; 44 | variate?: any; 45 | variates: any[]; 46 | rules: Rule[]; 47 | user: User | undefined; 48 | configId: number; 49 | }; 50 | 51 | export type PostEvalCtx = EvalCtx & { result: unknown }; 52 | 53 | export type OnEvalApi = { 54 | setUser: (newUser: unknown) => void; 55 | }; 56 | 57 | export type onPostEvaluateHook = (ctx: EvalCtx & { result: unknown }) => void; 58 | 59 | export type Plugin = { 60 | /** 61 | * Invoked when a plugin is initialized. 62 | */ 63 | onPluginInit?: OnPluginInitHook; 64 | 65 | /** 66 | * Invoked when a user is identifier. 67 | */ 68 | onIdentify?: OnPluginIdentifyHook; 69 | 70 | /** 71 | * Invoked when the evaluation starts. 72 | */ 73 | onPreEvaluate?: onPreEvaluateHook; 74 | 75 | /** 76 | * Invoked when the evaluation ends. 77 | */ 78 | onPostEvaluate?: onPostEvaluateHook; 79 | 80 | /** 81 | * Whether a literal matches this plugin. 82 | * 83 | * The matcher decides whether `evaluate` is invoked, Use `isNone` to skip evaluation. 84 | * 85 | * @remark Consider a helper: `isNone`, `isAny`, `isBoolean`, `isString`, `isNumber` or `isDate`. 86 | */ 87 | matcher: M | M[]; 88 | 89 | /** 90 | * Evaluates the matched literal. 91 | * 92 | * @remark The matcher infers the type of your literal. 93 | */ 94 | evaluate?: (ctx: EvaluateContext>) => boolean; 95 | }; 96 | 97 | type EvaluateContext = EvalCtx & { 98 | literal: TLiteral; 99 | }; 100 | 101 | type InferLiteralType> = M extends Matcher 102 | ? T 103 | : any; 104 | 105 | export function createPlugin(plugin: Plugin) { 106 | return plugin; 107 | } 108 | 109 | export function createNopPlugin(): Plugin> { 110 | return { 111 | matcher: isNone, 112 | }; 113 | } 114 | -------------------------------------------------------------------------------- /packages/keat/src/plugins/rollouts.ts: -------------------------------------------------------------------------------- 1 | import {User} from "../types"; 2 | import {DEFAULT_GET_USER_ID} from "../utils"; 3 | import {createPlugin} from "../plugin"; 4 | import {isNumber} from "../matchers"; 5 | 6 | type RolloutsPluginOptions = { 7 | getUserId?: (user: User) => string; 8 | hash?: HashFn; 9 | }; 10 | 11 | type HashFn = (userId: string, feature: string) => number; // number between 0-100. 12 | 13 | const DEFAULT_SEED = 1042019; 14 | const DEFAULT_HASH: HashFn = (userId, feature) => { 15 | const seed = murmurHash(feature, DEFAULT_SEED); 16 | return (murmurHash(userId, seed) % 100) + 1; 17 | }; 18 | 19 | export const rollouts = (options?: RolloutsPluginOptions) => { 20 | const userFn = options?.getUserId ?? DEFAULT_GET_USER_ID; 21 | const hashFn = options?.hash ?? DEFAULT_HASH; 22 | 23 | // Threshold should accumulate over multi-variates 24 | // e.g. variates: ["a", "b", "c"] and rules: [30,50,20] 25 | // This has 30% chance to be "a" and 50% chance to be "b". 26 | // For this to happen threshold has to be 80% for "b". 27 | let threshold = 0; 28 | 29 | return createPlugin({ 30 | matcher: isNumber, 31 | onPreEvaluate() { 32 | threshold = 0; 33 | }, 34 | evaluate({ feature, user, literal }) { 35 | const percentage = user 36 | ? hashFn(userFn(user), feature) 37 | : Math.floor(Math.random() * 101); 38 | 39 | threshold = threshold + literal; 40 | return percentage <= threshold; 41 | }, 42 | }); 43 | }; 44 | 45 | /** 46 | * Fast, non-cryptographic hash function. 47 | * 48 | * All credits go to Perezd's node-murmurhash. 49 | * 50 | * @see https://en.wikipedia.org/wiki/MurmurHash 51 | * @see https://github.com/perezd/node-murmurhash 52 | */ 53 | export function murmurHash(key: string | Uint8Array, seed: number): number { 54 | if (typeof key === "string") key = new TextEncoder().encode(key); 55 | 56 | const remainder = key.length & 3; 57 | const bytes = key.length - remainder; 58 | const c1 = 0xcc9e2d51; 59 | const c2 = 0x1b873593; 60 | 61 | let i = 0; 62 | let h1 = seed; 63 | let k1, h1b; 64 | 65 | while (i < bytes) { 66 | k1 = 67 | (key[i] & 0xff) | 68 | ((key[++i] & 0xff) << 8) | 69 | ((key[++i] & 0xff) << 16) | 70 | ((key[++i] & 0xff) << 24); 71 | ++i; 72 | 73 | k1 = 74 | ((k1 & 0xffff) * c1 + ((((k1 >>> 16) * c1) & 0xffff) << 16)) & 0xffffffff; 75 | k1 = (k1 << 15) | (k1 >>> 17); 76 | k1 = 77 | ((k1 & 0xffff) * c2 + ((((k1 >>> 16) * c2) & 0xffff) << 16)) & 0xffffffff; 78 | 79 | h1 ^= k1; 80 | h1 = (h1 << 13) | (h1 >>> 19); 81 | h1b = 82 | ((h1 & 0xffff) * 5 + ((((h1 >>> 16) * 5) & 0xffff) << 16)) & 0xffffffff; 83 | h1 = (h1b & 0xffff) + 0x6b64 + ((((h1b >>> 16) + 0xe654) & 0xffff) << 16); 84 | } 85 | 86 | k1 = 0; 87 | 88 | switch (remainder) { 89 | case 3: 90 | k1 ^= (key[i + 2] & 0xff) << 16; 91 | case 2: 92 | k1 ^= (key[i + 1] & 0xff) << 8; 93 | case 1: 94 | k1 ^= key[i] & 0xff; 95 | 96 | k1 = 97 | ((k1 & 0xffff) * c1 + ((((k1 >>> 16) * c1) & 0xffff) << 16)) & 98 | 0xffffffff; 99 | k1 = (k1 << 15) | (k1 >>> 17); 100 | k1 = 101 | ((k1 & 0xffff) * c2 + ((((k1 >>> 16) * c2) & 0xffff) << 16)) & 102 | 0xffffffff; 103 | h1 ^= k1; 104 | } 105 | 106 | h1 ^= key.length; 107 | 108 | h1 ^= h1 >>> 16; 109 | h1 = 110 | ((h1 & 0xffff) * 0x85ebca6b + 111 | ((((h1 >>> 16) * 0x85ebca6b) & 0xffff) << 16)) & 112 | 0xffffffff; 113 | h1 ^= h1 >>> 13; 114 | h1 = 115 | ((h1 & 0xffff) * 0xc2b2ae35 + 116 | ((((h1 >>> 16) * 0xc2b2ae35) & 0xffff) << 16)) & 117 | 0xffffffff; 118 | h1 ^= h1 >>> 16; 119 | 120 | return h1 >>> 0; 121 | } 122 | -------------------------------------------------------------------------------- /packages/keat/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { AnyFeatures, Config, Feature, Literal, Rule } from "./types"; 2 | 3 | /** 4 | * Utility which transforms an environment variable into a properly typed array. 5 | * 6 | * @example 7 | * fromEnv(process.env.ENABLE_UI_TO) 8 | * 9 | * `ENABLE_UI_TO=true` // enabled 10 | * `ENABLE_UI_TO=developers,5` // `['developers', 5]` 11 | */ 12 | 13 | /** 14 | * Retrieve the identifier of a user. 15 | */ 16 | export const DEFAULT_GET_USER_ID = (user: any) => { 17 | return user["id"] ?? user["sub"] ?? user["email"]; 18 | }; 19 | 20 | /** 21 | * Create a user from an identifier. 22 | */ 23 | export const DEFAULT_CREATE_USER = (id: string) => ({ id }); 24 | 25 | export function takeStrings(rule: Rule): string[] { 26 | if (typeof rule === "string") return [rule]; 27 | if (typeof rule !== "object") return []; 28 | const arr = mutable(rule["OR"]) ?? []; 29 | return arr.filter((a): a is string => typeof a === "string"); 30 | } 31 | 32 | export function takeNumbers(rule: Rule): number[] { 33 | if (typeof rule === "number") return [rule]; 34 | if (typeof rule !== "object") return []; 35 | const arr = mutable(rule["OR"]) ?? []; 36 | return arr.filter((a): a is number => typeof a === "number"); 37 | } 38 | 39 | export function takeBooleans(rule: Rule): boolean[] { 40 | if (typeof rule === "boolean") return [rule]; 41 | if (typeof rule !== "object") return []; 42 | const arr = mutable(rule["OR"]) ?? []; 43 | return arr.filter((a): a is boolean => typeof a === "boolean"); 44 | } 45 | 46 | export function mutable(x?: readonly T[]): T[] | undefined { 47 | return x as T[]; 48 | } 49 | 50 | export function getVariatesMap( 51 | features: Record 52 | ): Record { 53 | const names = Object.keys(features); 54 | const entries = names.map((name) => [name, getVariates(features, name)]); 55 | return Object.fromEntries(entries); 56 | } 57 | 58 | export function getVariates(features: AnyFeatures, name: string): any[] { 59 | const feat = features[name]; 60 | return typeof feat === "object" && "variates" in feat 61 | ? mutable(feat.variates) ?? [true, false] 62 | : [true, false]; 63 | } 64 | 65 | export function getRules( 66 | features: AnyFeatures, 67 | config: Config, 68 | name: string, 69 | configId: number 70 | ): Rule[] | undefined { 71 | const feat = features[name]; 72 | const remote = config[name]; 73 | const local = isRule(feat) ? feat : (feat["when"] as Rule | Rule[]); 74 | return configId === 0 ? normalize(local) : normalize(remote ?? local); 75 | } 76 | 77 | function isRule(x: unknown): x is Rule { 78 | return ( 79 | typeof x === "boolean" || 80 | typeof x === "string" || 81 | typeof x === "number" || 82 | (typeof x === "object" && x !== null && "OR" in x) 83 | ); 84 | } 85 | 86 | export function isLiteral(rule: Rule): rule is Literal { 87 | const t = typeof rule; 88 | return t === "string" || t === "number" || t === "boolean"; 89 | } 90 | 91 | function normalize(rule: Rule | Rule[] | undefined): Rule[] | undefined { 92 | return Array.isArray(rule) ? rule : rule === undefined ? undefined : [rule]; 93 | } 94 | 95 | export function flagsToConfig( 96 | flags: Record, 97 | variates: Record 98 | ): Config { 99 | const config: Config = {}; 100 | 101 | for (const [feature, variate] of Object.entries(flags)) { 102 | const variations = variates[feature]; 103 | if (!variations) continue; 104 | const rule = variations.map((v) => v === variate); 105 | const isFalse = rule.length === 2 && rule[0] === false; 106 | const isTrue = rule.length === 2 && rule[0] === true; 107 | const simplifiedRule = isFalse ? false : isTrue ? true : rule; 108 | config[feature] = simplifiedRule; 109 | } 110 | 111 | return config; 112 | } 113 | -------------------------------------------------------------------------------- /examples/react-vite/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/keat/src/keat.ts: -------------------------------------------------------------------------------- 1 | import { load } from "./display"; 2 | import type { EvalCtx, OnEvalApi, Plugin } from "./plugin"; 3 | import type { 4 | AnyFeatures, 5 | Config, 6 | Display, 7 | KeatApi, 8 | KeatInit, 9 | Rule, 10 | User, 11 | } from "./types"; 12 | import { getRules, getVariatesMap, isLiteral } from "./utils"; 13 | 14 | export type ExtractFeatures = K extends KeatApi ? keyof K : never; 15 | 16 | export type Listener = (config?: Config) => void; 17 | export type Unsubscribe = () => void; 18 | 19 | export function keatCore({ 20 | features, 21 | display = "swap", 22 | plugins = [], 23 | }: KeatInit): KeatApi { 24 | const names = Object.keys(features); 25 | const variatesMap = getVariatesMap(features); 26 | let defaultDisplay = display; 27 | let defaultUser: User | undefined = undefined; 28 | 29 | let configId = 0; 30 | let config: Config = {}; 31 | const setConfig = (newConfig: Config) => { 32 | configId += 1; 33 | config = newConfig ?? {}; 34 | }; 35 | 36 | let listeners: Listener[] = []; 37 | const handleChange = () => { 38 | listeners.forEach((l) => l(config)); 39 | }; 40 | 41 | const initPromise = Promise.allSettled( 42 | plugins.map((p) => 43 | p.onPluginInit?.( 44 | { features: names, variates: variatesMap }, 45 | { 46 | setConfig, 47 | onChange: handleChange, 48 | } 49 | ) 50 | ) 51 | ).then(() => { 52 | if (configId === 0) return; 53 | handleChange(); 54 | }); 55 | 56 | let loader = load(initPromise); 57 | 58 | const evaluate = ( 59 | feature: string, 60 | user: User | undefined, 61 | configId: number 62 | ): any => { 63 | const variates = variatesMap[feature]; 64 | const rules = getRules(features, config, feature, configId); 65 | if (!rules) return variates[variates.length - 1]; 66 | 67 | let result: unknown; 68 | let ctx: EvalCtx = { 69 | feature, 70 | variates, 71 | rules, 72 | user, 73 | configId, 74 | }; 75 | 76 | const preApi: OnEvalApi = { 77 | setUser: (newUser) => { 78 | ctx.user = newUser as User; 79 | }, 80 | }; 81 | 82 | plugins.forEach((p) => p.onPreEvaluate?.(ctx, preApi)); 83 | 84 | for (let i = 0; i < variates.length; i++) { 85 | const variate = variates[i]; 86 | const rule = rules[i]; 87 | const ok = evaluateVariate({ ...ctx, variate }, plugins, rule); 88 | 89 | if (ok) { 90 | result = variate; 91 | break; 92 | } 93 | } 94 | 95 | if (result === undefined) { 96 | const index = rules.findIndex((v) => v === true) ?? -1; 97 | result = index === -1 ? variates[variates.length - 1] : variates[index]; 98 | } 99 | 100 | plugins.forEach((p) => p.onPostEvaluate?.({ ...ctx, result })); 101 | 102 | return result; 103 | }; 104 | 105 | return { 106 | ready: (display: Display = defaultDisplay) => loader.ready(display), 107 | configure: (newConfig: Config) => { 108 | setConfig(newConfig); 109 | handleChange(); 110 | }, 111 | identify: (user?: User, noReload?: boolean) => { 112 | defaultUser = user; 113 | plugins.forEach((p) => p.onIdentify); 114 | if (noReload) return; 115 | const currentId = configId; 116 | loader = load( 117 | Promise.allSettled(plugins.map((p) => p.onIdentify?.({ user }))).then( 118 | () => { 119 | if (configId === currentId) return; 120 | handleChange(); 121 | } 122 | ) 123 | ); 124 | }, 125 | setDisplay: (display: Display) => (defaultDisplay = display), 126 | variation: ( 127 | feature: TFeature, 128 | user: User | undefined = defaultUser, 129 | display: Display = defaultDisplay 130 | ) => { 131 | const useLatest = loader.useLatest(display); 132 | return evaluate(feature as string, user, useLatest ? configId : 0); 133 | }, 134 | onChange: (listener: Listener): Unsubscribe => { 135 | listeners.push(listener); 136 | return () => { 137 | listeners = listeners.filter((l) => l === listener); 138 | }; 139 | }, 140 | }; 141 | } 142 | 143 | function evaluateVariate( 144 | ctx: EvalCtx, 145 | plugins: Plugin[], 146 | rule: Rule | undefined 147 | ): boolean { 148 | if (rule === undefined) return false; 149 | return isLiteral(rule) 150 | ? plugins.some((p) => { 151 | const matchers = Array.isArray(p.matcher) ? p.matcher : [p.matcher]; 152 | for (const matcher of matchers) { 153 | const literal = matcher(rule); 154 | if (literal === null) continue; 155 | return p.evaluate?.({ ...ctx, literal }) ?? false; 156 | } 157 | }) 158 | : rule.OR.some((r) => evaluateVariate(ctx, plugins, r)); 159 | } 160 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Keat 2 | 3 | Progressive and type-safe feature flags. 4 | 5 | An easy way to increase your deployment frequency and reduce stress of releases. 6 | 7 | ## Key Features 8 | 9 | - 🚀 Progressive rollouts, 🎯 targeted audiences and 📅 scheduled launches. 10 | - 🛠 Remote configuration without vendor lock-in. 11 | - 💙 Amazing TypeScript support. 12 | - 💡 Framework agnostic with React adaptor included. 13 | - 🌳 Lightweight core with tree shakeable plugins. 14 | - 🧪 Bi- and multivariates of any type. 15 | 16 | ## Getting started 17 | 18 | Start by adding Keat to your codebase: 19 | 20 | ```bash 21 | npm install keat 22 | ``` 23 | 24 | After installing Keat, you can define your first **feature** together with its **rule**. 25 | 26 | ```typescript 27 | import { keatCore } from "keat"; 28 | 29 | const { variation } = keatCore({ 30 | features: { 31 | recommendations: true, 32 | }, 33 | }); 34 | 35 | variation("recommendations") === true; 36 | ``` 37 | 38 | By default the rule can either be `true` or `false`, respectively to enable or disable it. 39 | This is not very useful so let's continue by adding **plugins** to supercharge Keat. 40 | 41 | ### Enable features for particular users 42 | 43 | Enabling features for particular users allows you to test in production and preview releases to your adventurous users. 44 | 45 | To do this you use the `audience` plugin. 46 | This plugin looks whether the rule contains its name and enables the feature when its function evaluates truthy. 47 | 48 | ```typescript 49 | import { keatCore, audience } from "keat"; 50 | 51 | const { variation } = keatCore({ 52 | features: { 53 | recommendations: "staff", 54 | }, 55 | plugins: [audience("staff", (user) => user.email?.endsWith("example.io"))], 56 | }); 57 | 58 | variation("recommendations", { email: "dev@example.io" }) === true; 59 | variation("recommendations", { email: "jef@gmail.com" }) === false; 60 | ``` 61 | 62 | ### Enable features for a percentage of users 63 | 64 | Enabling features for a percentage of users allows canary and A/B testing. 65 | By releasing to a small and gradually increasing amount of users you gain the confidence you need. 66 | 67 | To do this you use the `rollouts` plugin. 68 | This plugin takes the first `number` of a rule and enables the feature for a percentage of users equal to that amount. Under the hood a murmurhash will ensure sticky behavior across sessions for the same user. 69 | 70 | ```typescript 71 | import { keatCore, audience, rollouts } from "keat"; 72 | 73 | const { variation } = keatCore({ 74 | features: { 75 | recommendations: { OR: ["staff", 25] }, 76 | }, 77 | plugins: [ 78 | audience("staff", (user) => user.email?.endsWith("example.io")), 79 | rollouts(), 80 | ], 81 | }); 82 | 83 | variation("recommendations", { email: "dev@example.io" }) === true; 84 | variation("recommendations", { email: randomEmail() }); // `true` for 25% of users. 85 | ``` 86 | 87 | You might also wonder how multiple plugins relate to each other. 88 | Plugins are evaluated in FIFO order, so in this example the audiences are checked before the rollouts. 89 | The evaluation short-circuits whenever a plugin sets a result, 90 | and when none is set the default behavior is used instead. 91 | 92 | ### Toggle features remotely 93 | 94 | Toggling features is the bread and butter of any feature management tool. 95 | 96 | Keat uses **configuration** to toggle features. 97 | 98 | The format is a basic JSON object that maps the feature to its updated rule: 99 | 100 | ```json 101 | { 102 | "recommendations": { "OR": ["staff", 50] } 103 | } 104 | ``` 105 | 106 | The plain format combined with custom plugins means possibilities are endless: 107 | 108 | - Serve from Cloud Object Storage or embed it within your API. 109 | - Use CloudFlare at edge or tools like Firebase Remote configuration. 110 | - Open a WebSocket or use server-sent events to stream changes in real-time. 111 | 112 | Or you can use the build-in `remoteConfig` to fetch it from an endpoint: 113 | 114 | ```typescript 115 | import { keatCore, remoteConfig, audience, rollouts } from "keat"; 116 | 117 | const { variation } = keatCore({ 118 | features: { 119 | recommendations: false, 120 | }, 121 | plugins: [ 122 | remoteConfig("http://example.io/config", { interval: 300 }), 123 | audience("staff", (user) => user.email?.endsWith("example.io")), 124 | rollouts(), 125 | ], 126 | }); 127 | ``` 128 | 129 | ## Examples 130 | 131 | ### Public landing page 132 | 133 | Website without login or stable identity where you can still preview and A/B test optimal engagement. 134 | 135 | Consider embedding **configuration** at build time since modern CI can rebuild it within a minute or two. 136 | Environment variables favour operational simplicity over propagation speed. 137 | You can get all the benefits of feature flags without the burden of infrastructure. 138 | 139 | ```typescript 140 | import { keatCore, localConfig, …, rollouts } from "keat"; 141 | import featureJson from "./features.json"; 142 | 143 | export const keat = keatCore({ 144 | features: { 145 | search: 30, 146 | halloweenDesign: { OR: ["preview", "2022-10-20"] }, 147 | }, 148 | plugins: [ 149 | localConfig({ 150 | ...featureJson, 151 | search: fromEnv(process.env["TOGGLE_SEARCH"]), 152 | }), 153 | anonymous({ persist: true }), 154 | queryParam("preview"), // takes "preview" and toggles when "preview" is in the URL's query parameters. 155 | launchDay(), // takes all ISO 8601 date strings and toggles when the date is in the past. 156 | rollouts(), 157 | ], 158 | }); 159 | ``` 160 | 161 | ### Microservice with NodeJs 162 | 163 | Keat works both in the browser and on NodeJs. Use it to measure performance optimizations, gradually migrate to a new integration or degrade your services when there is trouble on the horizon. 164 | 165 | Keat is not restricted traditional boolean flags. Use **bi- or multi-variates of any type** to be more expressive in your feature flags. Keat will also properly infer the return type of your variates so you get immediate feedback on your usage. 166 | 167 | ```typescript 168 | import { keatCore, rollouts } from "keat"; 169 | 170 | export const keat = keatCore({ 171 | features: { 172 | enableJitCache: 50, 173 | notificationService: { 174 | variates: ["modern", "legacy"], 175 | when: 5, 176 | }, 177 | rateLimit: { 178 | variates: [ 179 | { 180 | level: "default", 181 | average: 1000, 182 | burst: 2000 183 | }, 184 | { 185 | level: "degraded", 186 | average: 500, 187 | burst: 800, 188 | }, 189 | { 190 | level: "disaster", 191 | average: 100, 192 | burst: 150, 193 | }, 194 | ], 195 | when: [false, true, false], 196 | }, 197 | } as const, // tip: use `as const` to narrow the return types 198 | plugins: [rollouts()], 199 | }); 200 | 201 | keat.variation("notificationService"); 202 | ReturnType = { level: string; average: number; burst: number}; 203 | ReturnType = "modern" | "legacy"; // or `string` without `as const` 204 | ``` 205 | 206 | ### SaaS application with React 207 | 208 | Modern web application where developers can test in production, gather feedback through early previews and progressively rollout to maximise the chance of success. 209 | 210 | Your remote configuration might be slow for a variety of reasons (e.g. viewer has slow 3G). 211 | With **feature display** you can optimize individual boundaries instead of blocking your whole application. 212 | It will feel familiar if you've worked with `font-display` before ([Playground](https://font-display.glitch.me/), [MDN Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display)). 213 | 214 | ```tsx 215 | import { keatReact, audience, remoteConfig, rollouts } from "keat"; 216 | 217 | const { useKeat, FeatureBoundary } = keatReact({ 218 | features: { 219 | search: false, 220 | redesign: false, 221 | sortAlgorithm: { 222 | variates: ["quicksort", "insertionSort", "heapsort"], 223 | }, 224 | } as const, 225 | plugins: [ 226 | remoteConfig("https://example.io/slowConfig", { interval: 300 }), 227 | audience("staff", (user) => user.email?.endsWith("example.io")), 228 | audience("preview", (user) => user.settings.previewEnabled), 229 | rollouts(), 230 | ], 231 | }); 232 | 233 | export function App() { 234 | const { variation } = useKeat(); 235 | 236 | return ( 237 |
238 |

Keat

239 | 240 | Your old design

}> 241 |

Your new design

242 |
243 | 244 | } 248 | > 249 | 250 | 251 | 252 | 253 |
254 | ); 255 | } 256 | ``` 257 | 258 | ## Plugins 259 | 260 | ### Build-in plugins 261 | 262 | Rules: 263 | 264 | - **audience** checks whether the rule contains its name and enables the feature when its function responds truthy. 265 | - **queryParam** checks whether the rule contains its name and enables the feature depending on the URLs query parameter. 266 | - **rollouts** takes the first `number` and enables the feature for a percentage of users equal to that amount. 267 | - **launchDay** takes all `ISO 8601 date strings` and enables the feature when the date is in the past. 268 | 269 | Configurations: 270 | 271 | - **localConfig** fetches your configuration from a local JSON file or environment variables. 272 | - **remoteConfig** fetches your configuration from a remote endpoint, which allows decoupling deploy from release. 273 | - **customConfig** fetches your configuration with a customizable fetch. 274 | 275 | Miscellaneous: 276 | 277 | - **cache** adds simple caching to your evaluations which improve performance. 278 | - **anonymous** adds a generated, stable identity, which allows reliable rollout results. 279 | 280 | ### Custom plugins 281 | 282 | Plugins are plain old JavaScript objects with a simple interface that hooks 283 | into the lifecycle of Keat. Checkout [the common plugin interface on GitHub](https://github.com/WitoDelnat/keat/blob/main/src/core/plugin.ts) to get a full view on the available context and API. 284 | 285 | Here is the code for the launch day plugin: 286 | 287 | ```typescript 288 | export const launchDay = () => { 289 | createPlugin({ 290 | matcher: isDate, 291 | evaluate({ literal }) { 292 | return literal.getTime() < Date.now(); 293 | }, 294 | }); 295 | }; 296 | ``` 297 | 298 | ## License 299 | 300 | MIT 301 | --------------------------------------------------------------------------------