├── .prettierrc ├── .gitmodules ├── docs ├── .nojekyll ├── assets │ ├── highlight.css │ └── search.js ├── index.html └── interfaces │ ├── FPToggleDetail.html │ └── FPConfig.html ├── .gitignore ├── .github ├── licenserc.yml └── workflows │ ├── semantic-title.yml │ ├── npm-publish.yml │ ├── test.yml │ ├── license.yml │ └── codeql.yml ├── tsconfig.json ├── test ├── fixtures │ ├── segments.json │ ├── toggles.json │ └── repo.json ├── FPUser.test.ts ├── Sync.test.ts ├── Event.test.ts ├── FeatureProbe.test.ts └── Evaluate.test.ts ├── src ├── index.ts ├── FPUser.ts ├── Sync.ts ├── type.ts ├── Event.ts ├── FeatureProbe.ts └── Evaluate.ts ├── typedoc.js ├── jest.config.ts ├── rollup.config.js ├── README.md ├── package.json ├── example └── demo.js └── LICENSE /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 120 4 | } 5 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "test/fixtures/spec"] 2 | path = test/fixtures/spec 3 | url = git@github.com:FeatureProbe/server-sdk-specification.git 4 | -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | TypeDoc added this file to prevent GitHub Pages from using Jekyll. You can turn off this behavior by setting the `githubPages` option to false. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .DS_Store 3 | typings 4 | 5 | # Logs 6 | logs 7 | *.log 8 | 9 | .vscode/ 10 | .idea/ 11 | 12 | coverage/ 13 | 14 | dist/ 15 | -------------------------------------------------------------------------------- /.github/licenserc.yml: -------------------------------------------------------------------------------- 1 | header: 2 | license: 3 | spdx-id: Apache-2.0 4 | copyright-owner: FeatureProbe 5 | 6 | paths: 7 | - 'src/**/*' 8 | - '**/*.ts' 9 | - '**/*.js' 10 | 11 | paths-ignore: 12 | - '**/*.md' 13 | - 'docs' 14 | - 'dist' 15 | 16 | comment: on-failure 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "es6" 7 | ], 8 | "declaration": true, 9 | "sourceMap": true, 10 | "outDir": "./dist", 11 | "strict": true, 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "esModuleInterop": true, 15 | "forceConsistentCasingInFileNames": true 16 | }, 17 | "include": [ 18 | "src/**/*" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /test/fixtures/segments.json: -------------------------------------------------------------------------------- 1 | { 2 | "segments": { 3 | "some_segment1-fjoaefjaam": { 4 | "key": "some_segment1", 5 | "unique_id": "some_segment1-fjoaefjaam", 6 | "version": 2, 7 | "rules": [ 8 | { 9 | "conditions": [ 10 | { 11 | "type": "string", 12 | "subject": "city", 13 | "predicate": "is one of", 14 | "objects": [ 15 | "4" 16 | ] 17 | } 18 | ] 19 | } 20 | ] 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /docs/assets/highlight.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --light-code-background: #FFFFFF; 3 | --dark-code-background: #1E1E1E; 4 | } 5 | 6 | @media (prefers-color-scheme: light) { :root { 7 | --code-background: var(--light-code-background); 8 | } } 9 | 10 | @media (prefers-color-scheme: dark) { :root { 11 | --code-background: var(--dark-code-background); 12 | } } 13 | 14 | :root[data-theme='light'] { 15 | --code-background: var(--light-code-background); 16 | } 17 | 18 | :root[data-theme='dark'] { 19 | --code-background: var(--dark-code-background); 20 | } 21 | 22 | pre, code { background: var(--code-background); } 23 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 FeatureProbe 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | export { FeatureProbe } from './FeatureProbe'; 18 | export { FPUser } from './FPUser'; 19 | export { FPConfig, FPToggleDetail } from './type'; 20 | -------------------------------------------------------------------------------- /typedoc.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 FeatureProbe 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | const pkg = require('./package.json'); 18 | const PKG_VERSION = pkg.version; 19 | 20 | module.exports = { 21 | out: './docs', 22 | name: `FeatureProbe Server Side SDK for Node.js (${PKG_VERSION})`, 23 | readme: 'none', 24 | entryPoints: ['./src/index.ts'] 25 | }; 26 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 FeatureProbe 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | module.exports = { 18 | roots: ["/test"], 19 | testMatch: [ 20 | "**/__tests__/**/*.+(ts|tsx|js)", 21 | "**/?(*.)+(spec|test).+(ts|tsx|js)" 22 | ], 23 | transform: { 24 | "^.+\\.(ts|tsx)$": "ts-jest" 25 | }, 26 | globals: { 27 | "ts-jest": { 28 | tsconfig: "tsconfig.json" 29 | } 30 | }, 31 | testEnvironment: "jsdom" 32 | }; 33 | -------------------------------------------------------------------------------- /.github/workflows/semantic-title.yml: -------------------------------------------------------------------------------- 1 | name: "Lint PR" 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | 10 | jobs: 11 | main: 12 | name: Validate PR title 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: amannn/action-semantic-pull-request@v4 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | with: 20 | wip: true 21 | ignoreLabels: | 22 | bot 23 | ignore-semantic-pull-request 24 | 25 | - name: Hint valid formats 26 | if: ${{ failure() }} 27 | uses: thollander/actions-comment-pull-request@v1.4.1 28 | with: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | message: | 31 | Please adjust your PR title to match the [Conventional Commits spec](https://www.conventionalcommits.org/). 32 | 33 | For example: 34 | - fix: fix http client code 35 | - feat: allow provided config object to extend other configs 36 | - refactor!: drop support for node 6 37 | - feat(ui): add button component 38 | -------------------------------------------------------------------------------- /test/FPUser.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 FeatureProbe 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { FPUser } from '../src'; 18 | 19 | test('simple', () => { 20 | const user = new FPUser().stableRollout('uniqKey') 21 | .with('city', 'shenzhen') 22 | .with('os', 'macos'); 23 | 24 | expect(user.getAttr('os')).toBe('macos'); 25 | expect(Object.keys(user.attrs).length).toBe(2); 26 | expect(user.key).toBe('uniqKey'); 27 | }); 28 | 29 | test('user auto generated key', () => { 30 | const user = new FPUser(); 31 | 32 | expect(user.key).toBeDefined(); 33 | expect(user.key.length).toBe(13); 34 | }); 35 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: Node.js Package 5 | 6 | on: 7 | release: 8 | types: [ created ] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | with: 16 | fetch-depth: 2 17 | submodules: recursive 18 | - uses: actions/setup-node@v3 19 | with: 20 | node-version: 16 21 | - run: npm ci 22 | - run: npm test 23 | 24 | publish-npm: 25 | needs: build 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@v3 29 | with: 30 | fetch-depth: 2 31 | submodules: recursive 32 | - uses: actions/setup-node@v3 33 | with: 34 | node-version: 16 35 | registry-url: https://registry.npmjs.org/ 36 | - run: npm ci 37 | - run: npm run build --if-present 38 | - run: npm publish 39 | env: 40 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 41 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | concurrency: 13 | group: >- 14 | ${{ github.workflow }}- 15 | ${{ github.ref_type }}- 16 | ${{ github.event.pull_request.number || github.sha }} 17 | cancel-in-progress: true 18 | 19 | jobs: 20 | build: 21 | runs-on: ubuntu-latest 22 | 23 | strategy: 24 | matrix: 25 | node-version: [12.x, 14.x, 16.x, 18.x] 26 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 27 | 28 | steps: 29 | - uses: actions/checkout@v3 30 | with: 31 | fetch-depth: 2 32 | submodules: recursive 33 | - name: Use Node.js ${{ matrix.node-version }} 34 | uses: actions/setup-node@v3 35 | with: 36 | node-version: ${{ matrix.node-version }} 37 | cache: 'npm' 38 | - run: npm ci 39 | - run: npm run build --if-present 40 | - run: npm run test:cov 41 | 42 | - name: Upload coverage to Codecov 43 | uses: codecov/codecov-action@v3 44 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 FeatureProbe 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import typescript from "rollup-plugin-typescript2"; 18 | import minify from "rollup-plugin-babel-minify"; 19 | import resolve from "rollup-plugin-node-resolve"; 20 | import commonjs from "rollup-plugin-commonjs"; 21 | import nodePolyfills from "rollup-plugin-node-polyfills"; 22 | import json from "@rollup/plugin-json"; 23 | import builtins from "rollup-plugin-node-builtins"; 24 | 25 | export default { 26 | input: "./src/index.ts", 27 | output: [ 28 | { 29 | file: "./dist/featureprobe-server-sdk-node.min.js", 30 | format: "cjs", 31 | name: "featureProbe" 32 | } 33 | ], 34 | plugins: [ 35 | nodePolyfills(), 36 | typescript({ tsconfigOverride: { compilerOptions: { module: "ES2015" } } }), 37 | builtins({ crypto: false }), 38 | resolve({ browser: true }), 39 | commonjs({ include: "node_modules/**" }), 40 | json(), 41 | minify({ comments: false }) 42 | ], 43 | external: [ 44 | "crypto" 45 | ] 46 | }; 47 | -------------------------------------------------------------------------------- /.github/workflows/license.yml: -------------------------------------------------------------------------------- 1 | name: "Check license & format" 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request_target: 7 | 8 | 9 | jobs: 10 | check: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | 15 | - name: Checkout commit 16 | if: github.event_name == 'push' 17 | uses: actions/checkout@v3 18 | 19 | - name: Checkout pull request 20 | if: github.event_name == 'pull_request_target' 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | uses: actions/checkout@v3 24 | with: 25 | repository: ${{ github.event.pull_request.head.repo.full_name }} 26 | ref: ${{ github.event.pull_request.head.ref }} 27 | 28 | 29 | - name: Check license headers 30 | uses: apache/skywalking-eyes@v0.3.0 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | with: 34 | config: .github/licenserc.yml 35 | mode: fix 36 | 37 | - name: Commit licensed files 38 | if: github.event_name == 'pull_request_target' 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | uses: EndBug/add-and-commit@v8.0.2 42 | with: 43 | default_author: github_actions 44 | message: "chore: add license header(s)" 45 | 46 | - name: Create pull request 47 | if: github.event_name == 'push' 48 | uses: peter-evans/create-pull-request@v3 49 | with: 50 | author: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com> 51 | commit-message: "chore: add license header(s)" 52 | title: "chore: add license header(s)" 53 | body: Add missing license header(s) in source and test code. 54 | branch: add-license 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FeatureProbe Server Side SDK for Node.js 2 | 3 | [![Top Language](https://img.shields.io/github/languages/top/FeatureProbe/server-sdk-node)](https://github.com/FeatureProbe/server-sdk-node) 4 | [![Coverage Status](https://coveralls.io/repos/github/FeatureProbe/server-sdk-node/badge.svg?branch=main)](https://coveralls.io/github/FeatureProbe/server-sdk-node?branch=main) 5 | [![Github Star](https://img.shields.io/github/stars/FeatureProbe/server-sdk-node)](https://github.com/FeatureProbe/server-sdk-node/stargazers) 6 | [![Apache-2.0 license](https://img.shields.io/github/license/FeatureProbe/FeatureProbe)](https://github.com/FeatureProbe/FeatureProbe/blob/main/LICENSE) 7 | 8 | [FeatureProbe](https://featureprobe.com/) is an open source feature management service. This SDK is used to control features in Node.js programs. This 9 | SDK is designed primarily for use in multi-user systems such as web servers and applications. 10 | 11 | ## Basic Terms 12 | 13 | Reading the short [Introduction](https://docs.featureprobe.com/reference/sdk-introduction) will help to understand the code blow more easily. [中文](https://docs.featureprobe.com/zh-CN/reference/sdk-introduction) 14 | 15 | ## How to Use This SDK 16 | 17 | See [SDK Doc](https://docs.featureprobe.com/how-to/Server-Side%20SDKs/node-sdk) for detail. [中文](https://docs.featureprobe.com/zh-CN/how-to/Server-Side%20SDKs/node-sdk). For more information about SDK API, please reference [SDK API](https://featureprobe.github.io/server-sdk-node/). 18 | 19 | ## Contributing 20 | 21 | We are working on continue evolving FeatureProbe core, making it flexible and easier to use. 22 | Development of FeatureProbe happens in the open on GitHub, and we are grateful to the 23 | community for contributing bugfixes and improvements. 24 | 25 | Please read [CONTRIBUTING](https://github.com/FeatureProbe/featureprobe/blob/master/CONTRIBUTING.md) 26 | for details on our code of conduct, and the process for taking part in improving FeatureProbe. 27 | 28 | ## License 29 | 30 | This project is licensed under the Apache 2.0 License - see the [LICENSE](LICENSE) file for details. 31 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | schedule: 8 | - cron: '0 18 * * 1' 9 | 10 | jobs: 11 | analyze: 12 | name: Analyze 13 | runs-on: ubuntu-latest 14 | permissions: 15 | actions: read 16 | contents: read 17 | security-events: write 18 | 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | language: [ 'javascript' ] 23 | 24 | steps: 25 | - name: Checkout repository 26 | uses: actions/checkout@v3 27 | with: 28 | fetch-depth: 2 29 | submodules: recursive 30 | 31 | # Initializes the CodeQL tools for scanning. 32 | - name: Initialize CodeQL 33 | uses: github/codeql-action/init@v2 34 | with: 35 | languages: ${{ matrix.language }} 36 | # If you wish to specify custom queries, you can do so here or in a config file. 37 | # By default, queries listed here will override any specified in a config file. 38 | # Prefix the list here with "+" to use these queries and those in the config file. 39 | 40 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 41 | # queries: security-extended,security-and-quality 42 | 43 | 44 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 45 | # If this step fails, then you should remove it and run the build manually (see below) 46 | - name: Autobuild 47 | uses: github/codeql-action/autobuild@v2 48 | 49 | # ℹ️ Command-line programs to run using the OS shell. 50 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 51 | 52 | # If the Autobuild fails above, remove it and uncomment the following three lines. 53 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 54 | 55 | # - run: | 56 | # echo "Run, Build Application using script" 57 | # ./location_of_script_within_repo/buildscript.sh 58 | 59 | - name: Perform CodeQL Analysis 60 | uses: github/codeql-action/analyze@v2 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "featureprobe-server-sdk-node", 3 | "version": "2.2.0", 4 | "description": "FeatureProbe Server Side SDK for Node.js", 5 | "main": "./dist/index.js", 6 | "types": "./dist/index.d.ts", 7 | "unpkg": "./dist/featureprobe-server-sdk-node.min.js", 8 | "jsdelivr": "./dist/featureprobe-server-sdk-node.min.js", 9 | "files": [ 10 | "dist" 11 | ], 12 | "scripts": { 13 | "test": "jest", 14 | "test:cov": "jest --coverage --no-cache --runInBand", 15 | "lint": "prettier ./", 16 | "build": "npm run build:ts && npm run build:web", 17 | "build:ts": "tsc", 18 | "build:web": "rollup -c rollup.config.js", 19 | "package": "npm run build && npm pack", 20 | "doc": "typedoc" 21 | }, 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/FeatureProbe/server-sdk-node.git" 25 | }, 26 | "keywords": [ 27 | "featureprobe", 28 | "server" 29 | ], 30 | "publishConfig": { 31 | "access": "public" 32 | }, 33 | "author": "FeatureProbe", 34 | "license": "Apache-2.0", 35 | "bugs": { 36 | "url": "https://github.com/FeatureProbe/server-sdk-node/issues" 37 | }, 38 | "homepage": "https://github.com/FeatureProbe/server-sdk-node", 39 | "devDependencies": { 40 | "@rollup/plugin-buble": "^0.21.3", 41 | "@rollup/plugin-json": "^4.1.0", 42 | "@rollup/plugin-typescript": "^8.3.2", 43 | "@types/jest": "^27.0.0", 44 | "@typescript-eslint/eslint-plugin": "^5.31.0", 45 | "eslint": "^8.20.0", 46 | "eslint-config-prettier": "^8.5.0", 47 | "eslint-plugin-check-file": "^1.2.2", 48 | "eslint-plugin-import": "^2.26.0", 49 | "fetch-mock": "^9.11.0", 50 | "jest": "^27.2.0", 51 | "jest-environment-jsdom": "^27.2.0", 52 | "prettier": "^2.7.1", 53 | "rollup": "^2.70.2", 54 | "rollup-plugin-babel-minify": "^10.0.0", 55 | "rollup-plugin-commonjs": "^10.1.0", 56 | "rollup-plugin-node-builtins": "^2.1.2", 57 | "rollup-plugin-node-polyfills": "^0.2.1", 58 | "rollup-plugin-node-resolve": "^5.2.0", 59 | "rollup-plugin-serve": "^1.1.0", 60 | "rollup-plugin-typescript": "^1.0.1", 61 | "rollup-plugin-typescript2": "^0.31.2", 62 | "ts-jest": "^27.1.0", 63 | "typedoc": "^0.23.10", 64 | "typescript": "^4.7.4" 65 | }, 66 | "dependencies": { 67 | "@types/semver": "^7.3.12", 68 | "isomorphic-fetch": "^3.0.0", 69 | "pino": "^7.10.0", 70 | "socket.io-client": "^4.5.3", 71 | "ts-node": "^10.9.1" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /test/Sync.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 FeatureProbe 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { Repository } from '../src/Evaluate'; 18 | import { Synchronizer } from '../src/Sync'; 19 | import fetchMock from 'fetch-mock'; 20 | 21 | const repoJson = require('./fixtures/repo.json'); 22 | const repo = new Repository(repoJson); 23 | 24 | test('start sync and wait for first resp', async () => { 25 | fetchMock.mock('https://test.featureprobe.io/toggles', 26 | JSON.stringify(repoJson), 27 | { overwriteRoutes: true }); 28 | const repo2 = new Repository({}); 29 | const synchronizer = new Synchronizer('node-sdk', 30 | new URL('https://test.featureprobe.io/toggles'), 31 | 1000, 32 | repo2 33 | ); 34 | await synchronizer.start(); 35 | 36 | // don't care about update timestamp and initialized flag for testing 37 | repo2.initialized = false; 38 | repo2.updatedTimestamp = 0; 39 | 40 | expect(repo2).toStrictEqual(repo); 41 | }); 42 | 43 | test('receive invalid json', async () => { 44 | fetchMock.mock('https://test.featureprobe.io/toggles', 45 | { body: '{' }, 46 | { overwriteRoutes: true }); 47 | const repo2 = new Repository({}); 48 | const synchronizer = new Synchronizer('node-sdk', 49 | new URL('https://test.featureprobe.io/toggles'), 50 | 1000, 51 | repo2 52 | ); 53 | await synchronizer.start(); 54 | await new Promise(r => setTimeout(r, 3000)); 55 | expect(repo2).toStrictEqual(new Repository({})); 56 | }); 57 | 58 | test('invalid url', async () => { 59 | fetchMock.mock('hppt://111', 404); 60 | 61 | const synchronizer = new Synchronizer('node-sdk', 62 | new URL('hppt://111'), // more explicit errors will be checked in FeatureProbe.constructor 63 | 1000, 64 | new Repository({}) 65 | ); 66 | await synchronizer.start(); 67 | 68 | await new Promise(r => setTimeout(r, 3000)); 69 | expect(synchronizer.repository).toStrictEqual(new Repository({})); 70 | expect(synchronizer.repository.initialized).toBe(false); 71 | expect(synchronizer.repository.updatedTimestamp).toBe(0); 72 | }); 73 | -------------------------------------------------------------------------------- /example/demo.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 FeatureProbe 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | const featureProbe = require('../dist/index.js'); 18 | const pino = require('pino'); 19 | 20 | const user = new featureProbe.FPUser().with('userId', '00001'); 21 | const user2 = new featureProbe.FPUser().with('userId', '00003'); 22 | 23 | // save log to file 24 | // const logToFile = pino.transport({ 25 | // targets: [ 26 | // { 27 | // level: 'info', 28 | // target: 'pino/file', 29 | // options: { 30 | // destination: './logs/info.log', 31 | // mkdir: true 32 | // } 33 | // } 34 | // ] 35 | // }); 36 | 37 | const FEATURE_PROBE_SERVER_URL = 'https://featureprobe.io/server'; // for featureprobe.io online demo 38 | // const FEATURE_PROBE_SERVER_URL = 'http://localhost:4007'; // for local docker 39 | 40 | const FEATURE_PROBE_SERVER_SDK_KEY = 'server-9b8b98cf444328ff1280a0757b26ec0abdacba76'; // change me 41 | 42 | const fpClient = new featureProbe.FeatureProbe({ 43 | remoteUrl: FEATURE_PROBE_SERVER_URL, 44 | serverSdkKey: FEATURE_PROBE_SERVER_SDK_KEY, 45 | refreshInterval: 5000, 46 | // logTransport: pino(logToFile) // uncomment this line to try out pino transport, by default a general pino client will be used 47 | }); 48 | 49 | const main = async () => { 50 | // wait until the repo has been initialized 51 | // await fpClient.start(); 52 | // add time limit 53 | await fpClient.start(1000); 54 | console.log('FeatureProbe evaluation boolean type toggle result is:', fpClient.booleanValue('campaign_allow_list', user, false)); 55 | console.log('FeatureProbe evaluation boolean type toggle detail is:', fpClient.booleanDetail('campaign_allow_list', user, false)); 56 | console.log(); 57 | console.log('FeatureProbe evaluation number type toggle result is:', fpClient.numberValue('promotion_campaign', user2, 0)); 58 | console.log('FeatureProbe evaluation number type toggle detail is:', fpClient.numberDetail('promotion_campaign', user2, 0)); 59 | 60 | await fpClient.close(); 61 | }; 62 | 63 | main().then(() => console.log('Enjoy using FeatureProbe!')); 64 | -------------------------------------------------------------------------------- /src/FPUser.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 FeatureProbe 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | 'use strict'; 18 | 19 | /** 20 | * A collection of attributes that can affect toggle evaluation, 21 | * usually corresponding to a user of your application. 22 | */ 23 | export class FPUser { 24 | 25 | private _key: string; 26 | private readonly _attrs: { [key: string]: string }; 27 | 28 | /** 29 | * Creates a new FPUser. 30 | * 31 | * @param stableRollout sets user with a unique id for percentage rollout, by default, a timestamp will be assigned as the key 32 | */ 33 | constructor(stableRollout?: string) { 34 | this._key = stableRollout ?? Date.now().toString(); 35 | this._attrs = {}; 36 | } 37 | 38 | /** 39 | * Gets the key of user. 40 | * 41 | * The key may be manually defined for {@link stableRollout}, or auto generated (timestamp). 42 | */ 43 | get key(): string { 44 | return this._key; 45 | } 46 | 47 | /** 48 | * Gets a copy of the user's attributions (custom values). 49 | */ 50 | get attrs(): { [key: string]: string } { 51 | return Object.assign({}, this._attrs); 52 | } 53 | 54 | /** 55 | * Sets user with a unique id for percentage rollout. 56 | * @param key user unique id for percentage rollout 57 | */ 58 | public stableRollout(key: string): FPUser { 59 | this._key = key; 60 | return this; 61 | } 62 | 63 | /** 64 | * Adds an attribute to the user. 65 | * @param attrName attribute name 66 | * @param attrValue attribute value 67 | * @return the FPUser 68 | */ 69 | public with(attrName: string, attrValue: string): FPUser { 70 | this._attrs[attrName] = attrValue; 71 | return this; 72 | } 73 | 74 | /** 75 | * Adds multiple attributes to the user. 76 | * @param attrs a map of attributions 77 | * @return the FPUser 78 | */ 79 | public extendAttrs(attrs: { [key: string]: string }): FPUser { 80 | for (const key in attrs) { 81 | this._attrs[key] = attrs[key]; 82 | } 83 | return this; 84 | } 85 | 86 | getAttr(attrName: string): string | undefined { 87 | return this._attrs[attrName]; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Sync.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 FeatureProbe 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | 'use strict'; 18 | 19 | import pino from 'pino'; 20 | 21 | require('isomorphic-fetch'); 22 | 23 | import { Repository } from './Evaluate'; 24 | 25 | const pkg = require('../package.json'); 26 | const UA = `Node/${pkg.version}`; 27 | 28 | export class Synchronizer { 29 | private _serverSdkKey: string; 30 | private _togglesUrl: string; 31 | private _repository: Repository; 32 | 33 | private _refreshInterval: number; 34 | private _timer?: NodeJS.Timer; 35 | 36 | private readonly _logger?: pino.Logger; 37 | 38 | get serverSdkKey(): string { 39 | return this._serverSdkKey; 40 | } 41 | 42 | set serverSdkKey(value: string) { 43 | this._serverSdkKey = value; 44 | } 45 | 46 | get togglesUrl(): string { 47 | return this._togglesUrl; 48 | } 49 | 50 | set togglesUrl(value: URL | string) { 51 | this._togglesUrl = new URL(value).toString(); 52 | } 53 | 54 | get repository(): Repository { 55 | return this._repository; 56 | } 57 | 58 | set repository(value: Repository) { 59 | this._repository = value; 60 | } 61 | 62 | get refreshInterval(): number { 63 | return this._refreshInterval; 64 | } 65 | 66 | set refreshInterval(value: number) { 67 | this._refreshInterval = value; 68 | } 69 | 70 | constructor(serverSdkKey: string, 71 | togglesUrl: URL | string, 72 | refreshInterval: number, 73 | repository: Repository, 74 | logger?: pino.Logger) { 75 | this._serverSdkKey = serverSdkKey; 76 | this._togglesUrl = new URL(togglesUrl).toString(); 77 | this._refreshInterval = refreshInterval; 78 | this._repository = repository; 79 | this._logger = logger; 80 | } 81 | 82 | public async start(): Promise { 83 | this._logger?.info(`Starting FeatureProbe polling repository with interval ${this._refreshInterval} ms`); 84 | this.stop(); 85 | this._timer = setInterval(() => this.syncNow(), this._refreshInterval); 86 | return this.syncNow(); 87 | } 88 | 89 | public stop() { 90 | if (this._timer !== undefined) { 91 | this._logger?.info('Closing FeatureProbe Synchronizer'); 92 | clearInterval(this._timer); 93 | delete this._timer; 94 | } 95 | } 96 | 97 | public async syncNow(): Promise { 98 | await fetch(this._togglesUrl, { 99 | method: 'GET', 100 | cache: 'no-cache', 101 | headers: { 102 | Authorization: this._serverSdkKey, 103 | 'Content-Type': 'application/json', 104 | UA: UA 105 | } 106 | }) 107 | .then(resp => resp.json()) 108 | .then(json => { 109 | this._logger?.debug(`Http response: ${json.status}`); 110 | this._logger?.debug(json, 'Http response body'); 111 | const latestRepo = new Repository(json); 112 | latestRepo.initialized = true; 113 | latestRepo.updatedTimestamp = Date.now(); 114 | Object.assign(this._repository, latestRepo); 115 | }) 116 | .catch((e: any) => this._logger?.error(e, 'Unexpected error from polling processor')); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/type.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 FeatureProbe 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { pino } from 'pino'; 18 | 19 | export interface FPUser { 20 | 21 | key: string; 22 | 23 | attrs: { [key: string]: string }; 24 | 25 | stableRollout(key: string): FPUser; 26 | 27 | with(attrName: string, attrValue: string): FPUser; 28 | 29 | extendAttrs(attrs: { [key: string]: string }): FPUser; 30 | } 31 | 32 | export interface FeatureProbe { 33 | 34 | start(): void; 35 | 36 | close(): void; 37 | 38 | flush(): void; 39 | 40 | booleanValue(key: string, user: FPUser, defaultValue: boolean): boolean; 41 | 42 | numberValue(key: string, user: FPUser, defaultValue: number): number; 43 | 44 | stringValue(key: string, user: FPUser, defaultValue: string): string; 45 | 46 | jsonValue(key: string, user: FPUser, defaultValue: any): any; 47 | 48 | booleanDetail(key: string, user: FPUser, defaultValue: boolean): FPToggleDetail; 49 | 50 | numberDetail(key: string, user: FPUser, defaultValue: number): FPToggleDetail; 51 | 52 | stringDetail(key: string, user: FPUser, defaultValue: string): FPToggleDetail; 53 | 54 | jsonDetail(key: string, user: FPUser, defaultValue: object): FPToggleDetail; 55 | } 56 | 57 | export interface FPToggleDetail { 58 | /** 59 | * Return value of a toggle for the current user. 60 | */ 61 | value: boolean | string | number | any; 62 | 63 | /** 64 | * The index of the matching rule. 65 | */ 66 | ruleIndex: number | null; 67 | 68 | /** 69 | * The index of the matching variation. 70 | */ 71 | variationIndex: number | null; 72 | 73 | /** 74 | * The version of the toggle. 75 | */ 76 | version: number | null; 77 | 78 | /** 79 | * The failed reason. 80 | */ 81 | reason: string | null; 82 | } 83 | 84 | export interface FPConfig { 85 | /** 86 | * The server SDK Key for authentication. 87 | */ 88 | serverSdkKey: string; 89 | 90 | /** 91 | * The unified URL to get toggles and post events. 92 | */ 93 | remoteUrl?: URL | string; 94 | 95 | /** 96 | * The specific URL to get toggles 97 | */ 98 | togglesUrl?: URL | string; 99 | 100 | /** 101 | * The specific URL to post events 102 | */ 103 | eventsUrl?: URL | string; 104 | 105 | /** 106 | * The specific URL to receive realtime event 107 | */ 108 | realtimeUrl?: URL | string; 109 | 110 | /** 111 | * The SDK check for updated in millisecond. 112 | */ 113 | refreshInterval?: number; 114 | 115 | /** 116 | * The max deep of prerequisite toggles 117 | */ 118 | prerequisiteMaxDeep ?: number; 119 | 120 | /** 121 | * Pino logger. 122 | * 123 | * If you want to use transport or advanced settings, 124 | * please define one instance and pass to this param. 125 | */ 126 | logger?: pino.Logger; 127 | } 128 | 129 | export interface AccessEvent { 130 | kind: string; 131 | time: number; 132 | key: string; 133 | value: boolean | string | number | Record; 134 | variationIndex: number; 135 | ruleIndex: number | null; 136 | version: number; 137 | user: string; 138 | } 139 | 140 | export interface CustomEvent { 141 | kind: string; 142 | name: string; 143 | time: number; 144 | value: unknown; 145 | user: string; 146 | } 147 | 148 | export interface DebugEvent extends AccessEvent { 149 | userDetail: FPUser; 150 | } -------------------------------------------------------------------------------- /test/Event.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 FeatureProbe 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import fetchMock from 'fetch-mock'; 18 | 19 | import { EventRecorder } from '../src/Event'; 20 | 21 | test('flush event', async () => { 22 | const fakeEventUrl = 'https://test.featureprobe.io/api/events'; 23 | const mockApi = fetchMock.mock(fakeEventUrl, 24 | { status: 200, body: '{' }, 25 | { overwriteRoutes: true }); 26 | 27 | const recorder = new EventRecorder('sdk key', fakeEventUrl, 1000); 28 | recorder.recordAccessEvent({ 29 | time: Date.now(), 30 | key: 'toggle key', 31 | value: 'eval value', 32 | version: 1, 33 | reason: 'default', 34 | index: -1 35 | }); 36 | recorder.recordAccessEvent({ 37 | time: Date.now(), 38 | key: 'toggle key', 39 | value: 'eval value', 40 | version: 2, 41 | reason: 'default', 42 | index: -1 43 | }); 44 | recorder.flush(); 45 | await new Promise(r => setTimeout(r, 2000)); 46 | 47 | expect(JSON.parse(mockApi.lastOptions()?.body?.toString() ?? '[]')[0].access.counters['toggle key']) 48 | .toHaveLength(2); 49 | }); 50 | 51 | test('record track event', async () => { 52 | const fakeEventUrl = 'https://test.featureprobe.io/api/events'; 53 | const mockApi = fetchMock.mock(fakeEventUrl, 54 | { status: 200, body: '{' }, 55 | { overwriteRoutes: true }); 56 | 57 | const recorder = new EventRecorder('sdk key', fakeEventUrl, 1000); 58 | recorder.recordTrackEvent({ 59 | kind: 'access', 60 | time: Date.now(), 61 | key: 'toggle key1', 62 | value: 'value1', 63 | variationIndex: 1, 64 | ruleIndex: 1, 65 | version: 2, 66 | user: '111', 67 | }); 68 | recorder.recordTrackEvent({ 69 | kind: 'access', 70 | time: Date.now(), 71 | key: 'toggle key2', 72 | value: 'value2', 73 | variationIndex: 2, 74 | ruleIndex: 1, 75 | version: 1, 76 | user: '222', 77 | }); 78 | recorder.flush(); 79 | await new Promise(r => setTimeout(r, 2000)); 80 | 81 | expect(JSON.parse(mockApi.lastOptions()?.body?.toString() ?? '[]')[0].events) 82 | .toHaveLength(2); 83 | }); 84 | 85 | test('invalid url', async () => { 86 | expect(() => new EventRecorder('sdk key', '??', 1000)).toThrow(); 87 | }); 88 | 89 | test('get snapshot', () => { 90 | const fakeEventUrl = 'https://test.featureprobe.io/api/events'; 91 | const mockApi = fetchMock.mock(fakeEventUrl, 92 | { status: 200, body: '{' }, 93 | { overwriteRoutes: true }); 94 | 95 | const recorder = new EventRecorder('sdk key', fakeEventUrl, 1000); 96 | recorder.recordAccessEvent({ 97 | time: Date.now(), 98 | key: 'toggle key', 99 | value: 'eval value', 100 | version: 1, 101 | reason: 'default', 102 | index: -1 103 | }); 104 | }); 105 | 106 | test('record after close', async () => { 107 | const fakeEventUrl = 'https://test.featureprobe.io/api/events'; 108 | const mockApi = fetchMock.mock(fakeEventUrl, 200, { overwriteRoutes: true }); 109 | 110 | const recorder = new EventRecorder('sdk key', fakeEventUrl, 1000); 111 | await recorder.stop(); 112 | recorder.recordAccessEvent({ 113 | time: Date.now(), 114 | key: 'toggle key', 115 | value: 'eval value', 116 | version: 1, 117 | reason: 'default', 118 | index: -1 119 | }); 120 | }); 121 | 122 | test('close twice', async () => { 123 | const fakeEventUrl = 'https://test.featureprobe.io/api/events'; 124 | const mockApi = fetchMock.mock(fakeEventUrl, 200, { overwriteRoutes: true }); 125 | 126 | const recorder = new EventRecorder('sdk key', fakeEventUrl, 1000); 127 | await recorder.stop(); 128 | await recorder.stop(); 129 | }); 130 | -------------------------------------------------------------------------------- /test/fixtures/toggles.json: -------------------------------------------------------------------------------- 1 | { 2 | "toggles": { 3 | "toggle_1": { 4 | "key": "toggle_1", 5 | "enabled": true, 6 | "forClient": true, 7 | "version": 1, 8 | "disabledServe": { 9 | "select": 1 10 | }, 11 | "defaultServe": { 12 | "split": { 13 | "distribution": [ 14 | [ 15 | [ 16 | 0, 17 | 3333 18 | ] 19 | ], 20 | [ 21 | [ 22 | 3333, 23 | 6666 24 | ] 25 | ], 26 | [ 27 | [ 28 | 6666, 29 | 10000 30 | ] 31 | ] 32 | ], 33 | "bucketBy": "user_set_key", 34 | "salt": "some_salt" 35 | } 36 | }, 37 | "rules": [ 38 | { 39 | "serve": { 40 | "select": 0 41 | }, 42 | "conditions": [ 43 | { 44 | "type": "string", 45 | "subject": "city", 46 | "predicate": "is one of", 47 | "objects": [ 48 | "1", 49 | "2", 50 | "3" 51 | ] 52 | } 53 | ] 54 | }, 55 | { 56 | "serve": { 57 | "select": 1 58 | }, 59 | "conditions": [ 60 | { 61 | "type": "segment", 62 | "predicate": "is in", 63 | "object": ["some_segment1-fjoaefjaam"] 64 | } 65 | ] 66 | } 67 | ], 68 | "variations": [ 69 | { 70 | "variation_0": "c2", 71 | "v": "v1" 72 | }, 73 | { 74 | "variation_1": "v2" 75 | }, 76 | { 77 | "variation_2": "v3" 78 | } 79 | ] 80 | }, 81 | "multi_condition_toggle": { 82 | "key": "multi_condition_toggle", 83 | "enabled": true, 84 | "forClient": true, 85 | "version": 1, 86 | "disabledServe": { 87 | "select": 1 88 | }, 89 | "defaultServe": { 90 | "select": 1 91 | }, 92 | "rules": [ 93 | { 94 | "serve": { 95 | "select": 0 96 | }, 97 | "conditions": [ 98 | { 99 | "type": "string", 100 | "subject": "city", 101 | "predicate": "is one of", 102 | "objects": [ 103 | "1", 104 | "2", 105 | "3" 106 | ] 107 | }, 108 | { 109 | "type": "string", 110 | "subject": "os", 111 | "predicate": "is one of", 112 | "objects": [ 113 | "mac", 114 | "linux" 115 | ] 116 | } 117 | ] 118 | } 119 | ], 120 | "variations": [ 121 | { 122 | "variation_0": "" 123 | }, 124 | { 125 | "disabled_key": "disabled_value" 126 | } 127 | ] 128 | }, 129 | "disabled_toggle": { 130 | "key": "disabled_toggle", 131 | "enabled": false, 132 | "version": 1, 133 | "disabledServe": { 134 | "select": 1 135 | }, 136 | "defaultServe": { 137 | "select": 0 138 | }, 139 | "rules": [], 140 | "variations": [ 141 | {}, 142 | { 143 | "disabled_key": "disabled_value" 144 | } 145 | ] 146 | } 147 | } 148 | } -------------------------------------------------------------------------------- /src/Event.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 FeatureProbe 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | 'use strict'; 18 | 19 | import pino from 'pino'; 20 | 21 | require('isomorphic-fetch'); 22 | import { AccessEvent, CustomEvent, DebugEvent } from './type'; 23 | 24 | const pkg = require('../package.json'); 25 | const UA = `Node/${pkg.version}`; 26 | 27 | interface IAccessEvent { 28 | time: number; 29 | key: string; 30 | value: any; 31 | index: number; 32 | version: number; 33 | reason: string | null; 34 | } 35 | 36 | interface IToggleCounter { 37 | value: any; 38 | version: number; 39 | index: number; 40 | count: number; 41 | } 42 | 43 | interface IAccess { 44 | startTime: number; 45 | endTime: number; 46 | counters: { [key: string]: IToggleCounter[] }; 47 | } 48 | 49 | export class EventRecorder { 50 | private _serverSdkKey: string; 51 | private _eventsUrl: string; 52 | 53 | private _closed: boolean; 54 | private _sendAccessQueue: IAccessEvent[]; 55 | private _sendEventQueue: (AccessEvent | CustomEvent)[]; 56 | private _taskQueue: AsyncBlockingQueue>; 57 | private _timer: NodeJS.Timer; 58 | private readonly _dispatch: Promise; 59 | 60 | private readonly _logger?: pino.Logger; 61 | 62 | get serverSdkKey(): string { 63 | return this._serverSdkKey; 64 | } 65 | 66 | set serverSdkKey(value: string) { 67 | this._serverSdkKey = value; 68 | } 69 | 70 | get eventsUrl(): string { 71 | return this._eventsUrl; 72 | } 73 | 74 | set eventsUrl(value: string) { 75 | this._eventsUrl = value; 76 | } 77 | 78 | set flushInterval(value: number) { 79 | clearInterval(this._timer); 80 | this._timer = setInterval(() => this.flush(), value); 81 | } 82 | 83 | constructor(serverSdkKey: string, 84 | eventsUrl: URL | string, 85 | flushInterval: number, 86 | logger?: pino.Logger) { 87 | this._serverSdkKey = serverSdkKey; 88 | this._eventsUrl = new URL(eventsUrl).toString(); 89 | this._closed = false; 90 | this._sendAccessQueue = []; 91 | this._sendEventQueue = []; 92 | this._taskQueue = new AsyncBlockingQueue>(); 93 | this._timer = setInterval(() => this.flush(), flushInterval); 94 | this._dispatch = this.startDispatch(); 95 | this._logger = logger; 96 | } 97 | 98 | public recordAccessEvent(event: IAccessEvent) { 99 | if (this._closed) { 100 | this._logger?.warn('Trying to push access record to a closed EventProcessor, omitted'); 101 | return; 102 | } 103 | this._sendAccessQueue.push(event); 104 | } 105 | 106 | public recordTrackEvent(trackEvents: AccessEvent | CustomEvent | DebugEvent): void { 107 | if (this._closed) { 108 | console.warn("Trying to push custom event record to a closed EventProcessor, omitted"); 109 | return; 110 | } 111 | this._sendEventQueue.push(trackEvents); 112 | } 113 | 114 | public flush() { 115 | if (this._closed) { 116 | this._logger?.warn('Trying to flush a closed EventProcessor, omitted'); 117 | return; 118 | } 119 | this._taskQueue.enqueue(this.doFlush()); 120 | } 121 | 122 | public async stop(): Promise { 123 | if (this._closed) { 124 | this._logger?.warn('EventProcessor is already closed'); 125 | return; 126 | } 127 | clearInterval(this._timer); 128 | this._closed = true; 129 | this._taskQueue.enqueue(this.doFlush()); 130 | await this._dispatch; 131 | } 132 | 133 | private async startDispatch(): Promise { 134 | while (!this._closed || !this._taskQueue.isEmpty()) { 135 | await this._taskQueue.dequeue(); 136 | } 137 | } 138 | 139 | private static prepareSendData(events: IAccessEvent[]): IAccess { 140 | let start = -1, end = -1; 141 | const counters: { [key: string]: IToggleCounter[] } = {}; 142 | for (const event of events) { 143 | if (start < 0 || start < event.time) { 144 | start = event.time; 145 | } 146 | if (end < 0 || end > event.time) { 147 | end = event.time; 148 | } 149 | 150 | if (counters[event.key] === undefined) { 151 | counters[event.key] = []; 152 | } 153 | let added = false; 154 | for (const counter of counters[event.key]) { 155 | if (counter.index === event.index 156 | && counter.version === event.version 157 | && counter.value === event.value) { 158 | counter.count++; 159 | added = true; 160 | break; 161 | } 162 | } 163 | if (!added) { 164 | counters[event.key].push({ 165 | index: event.index, 166 | version: event.version, 167 | value: event.value, 168 | count: 1 169 | } as IToggleCounter); 170 | } 171 | } 172 | return { 173 | startTime: start, 174 | endTime: end, 175 | counters: counters 176 | } as IAccess; 177 | } 178 | 179 | private async doFlush(): Promise { 180 | if (this._sendAccessQueue.length === 0 && this._sendEventQueue.length === 0) { 181 | return; 182 | } 183 | const events = Object.assign([], this._sendAccessQueue); 184 | const trackEvents = Object.assign([], this._sendEventQueue); 185 | 186 | this._sendAccessQueue = []; 187 | this._sendEventQueue = []; 188 | 189 | const eventRepos = [{ 190 | events: trackEvents, 191 | access: EventRecorder.prepareSendData(events) 192 | }]; 193 | 194 | await fetch(this._eventsUrl, { 195 | method: 'POST', 196 | cache: 'no-cache', 197 | headers: { 198 | Authorization: this._serverSdkKey, 199 | 'Content-Type': 'application/json', 200 | UA: UA 201 | }, 202 | body: JSON.stringify(eventRepos) 203 | }) 204 | .then(resp => 205 | this._logger?.debug(resp, 'Http response (event push)')) 206 | .catch(err => 207 | this._logger?.error(err, 'Failed to report access events') 208 | ); 209 | } 210 | } 211 | 212 | // cred: https://stackoverflow.com/questions/47157428/how-to-implement-a-pseudo-blocking-async-queue-in-js-ts 213 | class AsyncBlockingQueue { 214 | private _promises: Promise[]; 215 | private _resolvers: ((t: T) => void)[]; 216 | 217 | constructor() { 218 | this._resolvers = []; 219 | this._promises = []; 220 | } 221 | 222 | private _add() { 223 | this._promises.push(new Promise(resolve => { 224 | this._resolvers.push(resolve); 225 | })); 226 | } 227 | 228 | enqueue(t: T) { 229 | if (!this._resolvers.length) { 230 | this._add(); 231 | } 232 | this._resolvers.shift()?.(t); 233 | } 234 | 235 | dequeue(): Promise { 236 | if (!this._promises.length) { 237 | this._add(); 238 | } 239 | return this._promises.shift()!; 240 | } 241 | 242 | isEmpty() { 243 | return !this._promises.length; 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /test/fixtures/repo.json: -------------------------------------------------------------------------------- 1 | { 2 | "segments": { 3 | "some_segment1-fjoaefjaam": { 4 | "key": "some_segment1", 5 | "uniqueId": "some_segment1-fjoaefjaam", 6 | "version": 2, 7 | "rules": [ 8 | { 9 | "conditions": [ 10 | { 11 | "type": "string", 12 | "subject": "city", 13 | "predicate": "is one of", 14 | "objects": [ 15 | "4" 16 | ] 17 | } 18 | ] 19 | } 20 | ] 21 | } 22 | }, 23 | "toggles": { 24 | "bool_toggle": { 25 | "key": "bool_toggle", 26 | "enabled": true, 27 | "forClient": true, 28 | "version": 1, 29 | "disabledServe": { 30 | "select": 1 31 | }, 32 | "defaultServe": { 33 | "select": 0 34 | }, 35 | "rules": [ 36 | { 37 | "serve": { 38 | "select": 0 39 | }, 40 | "conditions": [ 41 | { 42 | "type": "string", 43 | "subject": "city", 44 | "predicate": "is one of", 45 | "objects": [ 46 | "1", 47 | "2", 48 | "3" 49 | ] 50 | } 51 | ] 52 | }, 53 | { 54 | "serve": { 55 | "select": 1 56 | }, 57 | "conditions": [ 58 | { 59 | "type": "segment", 60 | "predicate": "is in", 61 | "objects": [ 62 | "some_segment1-fjoaefjaam" 63 | ] 64 | } 65 | ] 66 | } 67 | ], 68 | "variations": [ 69 | true, 70 | false 71 | ] 72 | }, 73 | "number_toggle": { 74 | "key": "number_toggle", 75 | "forClient": true, 76 | "enabled": true, 77 | "version": 1, 78 | "disabledServe": { 79 | "select": 1 80 | }, 81 | "defaultServe": { 82 | "select": 0 83 | }, 84 | "rules": [ 85 | { 86 | "serve": { 87 | "select": 0 88 | }, 89 | "conditions": [ 90 | { 91 | "type": "string", 92 | "subject": "city", 93 | "predicate": "is one of", 94 | "objects": [ 95 | "1", 96 | "2", 97 | "3" 98 | ] 99 | } 100 | ] 101 | }, 102 | { 103 | "serve": { 104 | "select": 1 105 | }, 106 | "conditions": [ 107 | { 108 | "type": "segment", 109 | "predicate": "is in", 110 | "objects": [ 111 | "some_segment1-fjoaefjaam" 112 | ] 113 | } 114 | ] 115 | } 116 | ], 117 | "variations": [ 118 | 1, 119 | 2 120 | ] 121 | }, 122 | "string_toggle": { 123 | "key": "string_toggle", 124 | "forClient": true, 125 | "enabled": true, 126 | "version": 1, 127 | "disabledServe": { 128 | "select": 1 129 | }, 130 | "defaultServe": { 131 | "select": 0 132 | }, 133 | "rules": [ 134 | { 135 | "serve": { 136 | "select": 0 137 | }, 138 | "conditions": [ 139 | { 140 | "type": "string", 141 | "subject": "city", 142 | "predicate": "is one of", 143 | "objects": [ 144 | "1", 145 | "2", 146 | "3" 147 | ] 148 | } 149 | ] 150 | }, 151 | { 152 | "serve": { 153 | "select": 1 154 | }, 155 | "conditions": [ 156 | { 157 | "type": "segment", 158 | "predicate": "is in", 159 | "objects": [ 160 | "some_segment1-fjoaefjaam" 161 | ] 162 | } 163 | ] 164 | } 165 | ], 166 | "variations": [ 167 | "1", 168 | "2" 169 | ] 170 | }, 171 | "json_toggle": { 172 | "key": "json_toggle", 173 | "enabled": true, 174 | "forClient": true, 175 | "version": 1, 176 | "disabledServe": { 177 | "select": 1 178 | }, 179 | "defaultServe": { 180 | "split": { 181 | "distribution": [ 182 | [ 183 | [ 184 | 0, 185 | 3333 186 | ] 187 | ], 188 | [ 189 | [ 190 | 3333, 191 | 6666 192 | ] 193 | ], 194 | [ 195 | [ 196 | 6666, 197 | 10000 198 | ] 199 | ] 200 | ], 201 | "salt": "some_salt" 202 | } 203 | }, 204 | "rules": [ 205 | { 206 | "serve": { 207 | "select": 0 208 | }, 209 | "conditions": [ 210 | { 211 | "type": "string", 212 | "subject": "city", 213 | "predicate": "is one of", 214 | "objects": [ 215 | "1", 216 | "2", 217 | "3" 218 | ] 219 | } 220 | ] 221 | }, 222 | { 223 | "serve": { 224 | "select": 1 225 | }, 226 | "conditions": [ 227 | { 228 | "type": "segment", 229 | "predicate": "is in", 230 | "objects": [ 231 | "some_segment1-fjoaefjaam" 232 | ] 233 | } 234 | ] 235 | } 236 | ], 237 | "variations": [ 238 | { 239 | "variation_0": "c2", 240 | "v": "v1" 241 | }, 242 | { 243 | "variation_1": "v2" 244 | }, 245 | { 246 | "variation_2": "v3" 247 | } 248 | ] 249 | }, 250 | "multi_condition_toggle": { 251 | "key": "multi_condition_toggle", 252 | "enabled": true, 253 | "forClient": true, 254 | "version": 1, 255 | "disabledServe": { 256 | "select": 1 257 | }, 258 | "defaultServe": { 259 | "select": 1 260 | }, 261 | "rules": [ 262 | { 263 | "serve": { 264 | "select": 0 265 | }, 266 | "conditions": [ 267 | { 268 | "type": "string", 269 | "subject": "city", 270 | "predicate": "is one of", 271 | "objects": [ 272 | "1", 273 | "2", 274 | "3" 275 | ] 276 | }, 277 | { 278 | "type": "string", 279 | "subject": "os", 280 | "predicate": "is one of", 281 | "objects": [ 282 | "mac", 283 | "linux" 284 | ] 285 | } 286 | ] 287 | } 288 | ], 289 | "variations": [ 290 | { 291 | "variation_0": "" 292 | }, 293 | { 294 | "disabled_key": "disabled_value" 295 | } 296 | ] 297 | }, 298 | "disabled_toggle": { 299 | "key": "disabled_toggle", 300 | "enabled": false, 301 | "forClient": true, 302 | "version": 1, 303 | "disabledServe": { 304 | "select": 1 305 | }, 306 | "defaultServe": { 307 | "select": 0 308 | }, 309 | "rules": [], 310 | "variations": [ 311 | {}, 312 | { 313 | "disabled_key": "disabled_value" 314 | } 315 | ] 316 | }, 317 | "server_toggle": { 318 | "key": "server_toggle", 319 | "enabled": false, 320 | "forClient": false, 321 | "version": 1, 322 | "disabledServe": { 323 | "select": 1 324 | }, 325 | "defaultServe": { 326 | "select": 0 327 | }, 328 | "rules": [], 329 | "variations": [ 330 | {}, 331 | { 332 | "disabled_key": "disabled_value" 333 | } 334 | ] 335 | }, 336 | "not_in_segment": { 337 | "key": "not_in_segment", 338 | "enabled": true, 339 | "forClient": false, 340 | "version": 1, 341 | "disabledServe": { 342 | "select": 0 343 | }, 344 | "defaultServe": { 345 | "select": 0 346 | }, 347 | "rules": [ 348 | { 349 | "serve": { 350 | "select": 1 351 | }, 352 | "conditions": [ 353 | { 354 | "type": "segment", 355 | "predicate": "is not in", 356 | "objects": [ 357 | "some_segment1-fjoaefjaam" 358 | ] 359 | } 360 | ] 361 | } 362 | ], 363 | "variations": [ 364 | {}, 365 | { 366 | "not_in": true 367 | } 368 | ] 369 | } 370 | } 371 | } -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | FeatureProbe Server Side SDK for Node.js (2.2.0)
2 |
3 | 10 |
11 |
12 |
13 |
14 |

FeatureProbe Server Side SDK for Node.js (2.2.0)

15 |
16 |
17 |

Index

18 |
19 |

Classes

20 |
FPUser 21 | FeatureProbe 22 |
23 |
24 |

Interfaces

25 |
28 |
55 |
56 |

Generated using TypeDoc

57 |
-------------------------------------------------------------------------------- /test/FeatureProbe.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 FeatureProbe 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { FeatureProbe, FPToggleDetail, FPUser } from '../src'; 18 | import { Repository } from '../src/Evaluate'; 19 | import fetchMock from 'fetch-mock'; 20 | 21 | const repoJson = require('./fixtures/repo.json'); 22 | const unInitPrompt = 'not initialized'; 23 | 24 | const scenarios = require('./fixtures/spec/spec/toggle_simple_spec.json'); 25 | 26 | let originalConsole: () => void; 27 | 28 | beforeEach(() => { 29 | originalConsole = console.log; 30 | console.log = jest.fn(); 31 | }); 32 | 33 | afterEach(() => { 34 | console.log = originalConsole; 35 | }); 36 | 37 | test('init FeatureProbe client', async () => { 38 | fetchMock.mock('https://test.featureprobe.io/api/server-sdk/toggles', 39 | JSON.stringify(repoJson), 40 | { overwriteRoutes: true }); 41 | 42 | const fpClient = new FeatureProbe( 43 | { 44 | remoteUrl: 'https://test.featureprobe.io', 45 | serverSdkKey: 'sdk key', 46 | refreshInterval: 1000 47 | }); 48 | await fpClient.start(); 49 | expect(fpClient.initialized).toBeTruthy(); 50 | fpClient.flush(); 51 | await fpClient.close(); 52 | }); 53 | 54 | test('close client', async () => { 55 | fetchMock.mock('https://test.featureprobe.io/api/server-sdk/toggles', 56 | JSON.stringify(repoJson), 57 | { overwriteRoutes: true }); 58 | 59 | const fpClient = new FeatureProbe( 60 | { 61 | remoteUrl: 'https://test.featureprobe.io', 62 | serverSdkKey: 'sdk key', 63 | refreshInterval: 1000 64 | }); 65 | await fpClient.start(); 66 | await fpClient.close(); 67 | console.log(( fpClient as any )._repository.toggles); 68 | expect(Object.keys(( fpClient as any )._repository.toggles)).toHaveLength(0); 69 | }); 70 | 71 | test('invalid sdk key', async () => { 72 | expect(() => new FeatureProbe( 73 | { 74 | remoteUrl: 'https://test.featureprobe.io', 75 | serverSdkKey: '', 76 | refreshInterval: 1000 77 | })).toThrow(); 78 | 79 | expect(() => new FeatureProbe( 80 | { 81 | remoteUrl: 'https://test.featureprobe.io', 82 | // @ts-ignore 83 | serverSdkKey: null, 84 | refreshInterval: 1000 85 | })).toThrow(); 86 | }); 87 | 88 | test('invalid url', async () => { 89 | expect(() => new FeatureProbe( 90 | { 91 | remoteUrl: '?', 92 | serverSdkKey: 'aaa', 93 | refreshInterval: 1000 94 | })).toThrow(); 95 | }); 96 | 97 | test('no url', async () => { 98 | expect(() => new FeatureProbe( 99 | { 100 | serverSdkKey: 'aaa' 101 | })).toThrow(); 102 | }); 103 | 104 | test('repo not initialized', async () => { 105 | fetchMock.mock('https://test.featureprobe.io/api/server-sdk/toggles', 106 | 400, 107 | { overwriteRoutes: true }); 108 | 109 | const fpClient = new FeatureProbe({ 110 | remoteUrl: 'https://test.featureprobe.io', 111 | serverSdkKey: 'sdk key', 112 | refreshInterval: 1000 113 | }); 114 | await fpClient.start(); 115 | 116 | const fpUser = new FPUser().stableRollout('key11') 117 | .with('city', '4'); 118 | 119 | expect(fpClient.booleanValue('bool_toggle', fpUser, true)).toBe(true); 120 | const booleanDetail = fpClient.booleanDetail('bool_toggle', fpUser, true); 121 | expect(booleanDetail.value).toBe(true); 122 | expect(booleanDetail.reason).toBe(unInitPrompt); 123 | 124 | expect(fpClient.stringValue('string_toggle', fpUser, 'ss')).toBe('ss'); 125 | const stringDetail = fpClient.stringDetail('string_toggle', fpUser, 'sss'); 126 | expect(stringDetail.value).toBe('sss'); 127 | expect(stringDetail.reason).toBe(unInitPrompt); 128 | 129 | expect(fpClient.numberValue('number_toggle', fpUser, -3.2e10)).toBe(-3.2e10); 130 | const numberDetail = fpClient.numberDetail('number_toggle', fpUser, -3.2e10); 131 | expect(numberDetail.value).toBe(-3.2e10); 132 | expect(numberDetail.reason).toBe(unInitPrompt); 133 | 134 | expect(fpClient.jsonValue('json_toggle', fpUser, {})).toEqual({}); 135 | const jsonDetail = fpClient.jsonDetail('json_toggle', fpUser, { a: null }); 136 | expect(jsonDetail.value).toEqual({ a: null }); 137 | expect(jsonDetail.reason).toBe(unInitPrompt); 138 | }); 139 | 140 | test('test eval', async () => { 141 | fetchMock.mock('https://test.featureprobe.io/api/server-sdk/toggles', 142 | JSON.stringify(repoJson), 143 | { overwriteRoutes: true }); 144 | 145 | const fpClient = new FeatureProbe( 146 | { 147 | remoteUrl: 'https://test.featureprobe.io', 148 | serverSdkKey: 'sdk key', 149 | refreshInterval: 1000 150 | }); 151 | await fpClient.start(); 152 | 153 | const fpUser = new FPUser().stableRollout('key11') 154 | .with('city', '4'); 155 | 156 | expect(fpClient.booleanValue('bool_toggle', fpUser, true)).toBe(false); 157 | expect(fpClient.stringValue('string_toggle', fpUser, 'ss')).toBe('2'); 158 | expect(fpClient.numberValue('number_toggle', fpUser, -3.2e10)).toBe(2.0); 159 | expect(fpClient.jsonValue('json_toggle', fpUser, {})).not.toEqual({}); 160 | }); 161 | 162 | test('eval type mismatch', async () => { 163 | fetchMock.mock('https://test.featureprobe.io/api/server-sdk/toggles', 164 | JSON.stringify(repoJson), 165 | { overwriteRoutes: true }); 166 | 167 | const fpClient = new FeatureProbe( 168 | { 169 | remoteUrl: 'https://test.featureprobe.io', 170 | serverSdkKey: 'sdk key', 171 | refreshInterval: 1000 172 | }); 173 | await fpClient.start(); 174 | 175 | const fpUser = new FPUser().stableRollout('key11') 176 | .with('city', '4'); 177 | 178 | expect(fpClient.booleanValue('number_toggle', fpUser, true)).toBe(true); 179 | expect(fpClient.stringValue('bool_toggle', fpUser, 'ss')).toBe('ss'); 180 | expect(fpClient.numberValue('string_toggle', fpUser, -3.2e10)).toBe(-3.2e10); 181 | expect(fpClient.jsonValue('bool_toggle', fpUser, {})).toEqual({}); 182 | }); 183 | 184 | test('eval toggle not exist', async () => { 185 | fetchMock.mock('https://test.featureprobe.io/api/server-sdk/toggles', 186 | JSON.stringify(repoJson), 187 | { overwriteRoutes: true }); 188 | 189 | const fpClient = new FeatureProbe( 190 | { 191 | remoteUrl: 'https://test.featureprobe.io', 192 | serverSdkKey: 'sdk key', 193 | refreshInterval: 1000 194 | }); 195 | await fpClient.start(); 196 | 197 | const fpUser = new FPUser().stableRollout('key11') 198 | .with('city', '4'); 199 | 200 | expect(fpClient.booleanValue('not_exist_toggle', fpUser, true)).toBe(true); 201 | expect(fpClient.stringValue('not_exist_toggle', fpUser, 'ss')).toBe('ss'); 202 | expect(fpClient.numberValue('not_exist_toggle', fpUser, -3.2e10)).toBe(-3.2e10); 203 | expect(fpClient.jsonValue('not_exist_toggle', fpUser, {})).toEqual({}); 204 | }); 205 | 206 | test('test scenarios', async () => { 207 | fetchMock.mock('https://test.featureprobe.io/api/server-sdk/toggles', 208 | 200, { overwriteRoutes: true }); 209 | 210 | function expectDetail(detail: FPToggleDetail, exp: any, isJson: boolean=false) { 211 | if (isJson) { 212 | expect(detail.value).toStrictEqual(exp.value); 213 | } else { 214 | expect(detail.value).toBe(exp.value); 215 | } 216 | 217 | if (exp.reason !== undefined) { 218 | expect(detail.reason).toContain(exp.reason) 219 | } 220 | if (exp.ruleIndex !== undefined) { 221 | expect(detail.ruleIndex).toBe(exp.ruleIndex) 222 | } 223 | if (exp.noRuleIndex == true) { 224 | expect(detail.ruleIndex).toBeNull() 225 | } 226 | if (exp.version !== undefined) { 227 | expect(detail.version).toBe(exp.version) 228 | } 229 | } 230 | 231 | for (const scenario of scenarios.tests) { 232 | const { scenario: name, fixture } = scenario; 233 | const repo = new Repository(fixture); 234 | repo.initialized = true; 235 | 236 | const fpClient = new FeatureProbe( 237 | { 238 | remoteUrl: 'https://test.featureprobe.io', 239 | serverSdkKey: 'sdk key' 240 | }); 241 | ( fpClient as any )._repository = repo; 242 | 243 | for (const testCase of scenario.cases) { 244 | console.log(`starting execute scenario: ${name}, case: ${testCase.name}`); 245 | const userCase = testCase.user; 246 | const fpUser = new FPUser().stableRollout(userCase.key); 247 | for (const cv of userCase.customValues) { 248 | fpUser.with(cv.key, cv.value); 249 | } 250 | 251 | const funcCase = testCase.function; 252 | const { name: funcName, toggle: toggleKey, default: defaultValue } = funcCase; 253 | const expectValue = testCase.expectResult.value; 254 | 255 | switch (funcName) { 256 | case 'bool_value': 257 | expect(fpClient.booleanValue(toggleKey, fpUser, defaultValue)).toBe(expectValue); 258 | break; 259 | case 'bool_detail': 260 | expectDetail(fpClient.booleanDetail(toggleKey, fpUser, defaultValue), testCase.expectResult); 261 | break; 262 | case 'string_value': 263 | expect(fpClient.stringValue(toggleKey, fpUser, defaultValue)).toBe(expectValue); 264 | break; 265 | case 'string_detail': 266 | expectDetail(fpClient.stringDetail(toggleKey, fpUser, defaultValue), testCase.expectResult); 267 | break; 268 | case 'number_value': 269 | expect(fpClient.numberValue(toggleKey, fpUser, defaultValue)).toBe(expectValue); 270 | break; 271 | case 'number_detail': 272 | expectDetail(fpClient.numberDetail(toggleKey, fpUser, defaultValue), testCase.expectResult); 273 | break; 274 | case 'json_value': 275 | expect(fpClient.jsonValue(toggleKey, fpUser, defaultValue)).toStrictEqual(expectValue); 276 | break; 277 | case 'json_detail': 278 | expectDetail(fpClient.jsonDetail(toggleKey, fpUser, defaultValue), testCase.expectResult, true); 279 | break; 280 | } 281 | } 282 | } 283 | }); 284 | 285 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /src/FeatureProbe.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 FeatureProbe 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | 'use strict'; 18 | 19 | import { FPToggleDetail, FPConfig } from './type'; 20 | import { FPUser } from './FPUser'; 21 | import { Repository } from './Evaluate'; 22 | import { EventRecorder } from './Event'; 23 | import { Synchronizer } from './Sync'; 24 | import pino from 'pino'; 25 | import { io } from 'socket.io-client'; 26 | // import { DefaultEventsMap } from "@socket.io/component-emitter"; 27 | 28 | /** 29 | * A client for the FeatureProbe API. 30 | * Applications should instantiate a single {@link FeatureProbe} for the lifetime of their application. 31 | */ 32 | export class FeatureProbe { 33 | private readonly _remoteUrl: string; 34 | private readonly _togglesUrl: string; 35 | private readonly _eventsUrl: string; 36 | private readonly _realtimeUrl: string; 37 | private readonly _serverSdkKey: string; 38 | private readonly _refreshInterval: number; 39 | 40 | private readonly _eventRecorder: EventRecorder; 41 | private readonly _toggleSyncer: Synchronizer; 42 | private readonly _repository: Repository; 43 | private readonly _prerequisiteMaxDeep: number; 44 | 45 | private readonly _logger: pino.Logger; 46 | // private _socket?: Socket; 47 | 48 | get initialized(): boolean { 49 | return this._repository.initialized; 50 | } 51 | 52 | /** 53 | * Creates a new client instance that connects to FeatureProbe. 54 | * Undefined optional parameters will be set as the default configurations. 55 | * 56 | * @param remoteUrl url for FeatureProbe api server 57 | * @param togglesUrl url of FeatureProbe api server's toggle controller, leave it as blank to use the same api server as {@link remoteUrl} 58 | * @param eventsUrl url of FeatureProbe api server's event report controller, leave it as blank to use the same api server as {@link remoteUrl} 59 | * @param serverSdkKey key for your FeatureProbe environment 60 | * @param refreshInterval interval between polls to refresh local toggles 61 | * @param logger pino logger, if you want to use transport or advanced settings, please define one instance and pass to this param 62 | */ 63 | constructor( 64 | { 65 | serverSdkKey, 66 | remoteUrl, 67 | togglesUrl, 68 | eventsUrl, 69 | realtimeUrl, 70 | refreshInterval = 1000, 71 | prerequisiteMaxDeep = 20, 72 | logger 73 | }: FPConfig) { 74 | if (!serverSdkKey) { 75 | throw new Error('non empty serverSdkKey is required'); 76 | } 77 | if (refreshInterval <= 0) { 78 | throw new Error('refreshInterval is invalid'); 79 | } 80 | 81 | if (!remoteUrl && !togglesUrl) { 82 | throw new Error('remoteUrl or togglesUrl is required'); 83 | } 84 | if (!remoteUrl && !eventsUrl) { 85 | throw new Error('remoteUrl or eventsUrl is required'); 86 | } 87 | if (!remoteUrl && !realtimeUrl) { 88 | throw new Error('remoteUrl or realtimeUrl is required'); 89 | } 90 | 91 | if (!remoteUrl && !togglesUrl && !eventsUrl) { 92 | throw new Error('remoteUrl is required'); 93 | } 94 | 95 | this._serverSdkKey = serverSdkKey; 96 | this._refreshInterval = refreshInterval; 97 | 98 | this._remoteUrl = new URL(remoteUrl ?? '').toString(); 99 | this._togglesUrl = new URL(togglesUrl ?? remoteUrl + '/api/server-sdk/toggles').toString(); 100 | this._eventsUrl = new URL(eventsUrl ?? remoteUrl + '/api/events').toString(); 101 | this._realtimeUrl = new URL(realtimeUrl ?? remoteUrl + '/realtime').toString(); 102 | this._logger = logger ?? pino({ name: 'FeatureProbe' }); 103 | this._repository = new Repository({}); 104 | this._eventRecorder = new EventRecorder(this._serverSdkKey, this._eventsUrl, this._refreshInterval, this._logger); 105 | this._toggleSyncer = new Synchronizer(this._serverSdkKey, this._togglesUrl, this._refreshInterval, this._repository, this._logger); 106 | this._prerequisiteMaxDeep = prerequisiteMaxDeep; 107 | } 108 | 109 | /** 110 | * Initializes the toggle repository. 111 | * 112 | * @param startWait set time limit for initialization, if not set, this function won't be timeout 113 | */ 114 | public async start(startWait?: number) { 115 | this.connectSocket(); 116 | const promises: [Promise] = [this._toggleSyncer.start()]; 117 | let timeoutHandle: NodeJS.Timeout | undefined; 118 | if (startWait != null) { 119 | promises.push(new Promise((resolve, reject) => { 120 | timeoutHandle = setTimeout( 121 | () => reject(new Error(`Failed to initialize repository in ${startWait} ms`)), 122 | startWait 123 | ); 124 | })); 125 | } 126 | 127 | const start = new Date().valueOf(); 128 | await Promise.race(promises) 129 | .then(() => { 130 | clearTimeout(timeoutHandle); 131 | this._logger.info(`FeatureProbe client started, initialization cost ${new Date().valueOf() - start} ms`); 132 | }) 133 | .catch(e => this._logger.error('FeatureProbe client failed to initialize', e)); 134 | } 135 | 136 | /** 137 | * Closes the FeatureProbe client, this would properly clean the memory and report all events. 138 | */ 139 | public async close() { 140 | await this._eventRecorder.stop(); 141 | this._toggleSyncer.stop(); 142 | this._repository.clear(); 143 | this._logger.flush(); 144 | } 145 | 146 | /** 147 | * Manually events push. 148 | */ 149 | public flush() { 150 | this._eventRecorder.flush(); 151 | } 152 | 153 | /** 154 | * Gets the evaluated value of a boolean toggle. 155 | * @param key toggle key 156 | * @param user user to be evaluated 157 | * @param defaultValue default return value 158 | */ 159 | public booleanValue(key: string, user: FPUser, defaultValue: boolean): boolean { 160 | return this.toggleDetail(key, user, defaultValue, 'boolean').value as boolean; 161 | } 162 | 163 | /** 164 | * Gets the evaluated value of a number toggle. 165 | * @param key toggle key 166 | * @param user user to be evaluated 167 | * @param defaultValue default return value 168 | */ 169 | public numberValue(key: string, user: FPUser, defaultValue: number): number { 170 | return this.toggleDetail(key, user, defaultValue, 'number').value as number; 171 | } 172 | 173 | /** 174 | * Gets the evaluated value of a string toggle. 175 | * @param key toggle key 176 | * @param user user to be evaluated 177 | * @param defaultValue default return value 178 | */ 179 | public stringValue(key: string, user: FPUser, defaultValue: string): string { 180 | return this.toggleDetail(key, user, defaultValue, 'string').value as string; 181 | } 182 | 183 | /** 184 | * Gets the evaluated value of a json toggle. 185 | * @param key toggle key 186 | * @param user user to be evaluated 187 | * @param defaultValue default return value 188 | */ 189 | public jsonValue(key: string, user: FPUser, defaultValue: any): any { 190 | return this.toggleDetail(key, user, defaultValue, 'object').value; 191 | } 192 | 193 | /** 194 | * Gets the detailed evaluation results of a boolean toggle. 195 | * @param key toggle key 196 | * @param user user to be evaluated 197 | * @param defaultValue default return value 198 | */ 199 | public booleanDetail(key: string, user: FPUser, defaultValue: boolean): FPToggleDetail { 200 | return this.toggleDetail(key, user, defaultValue, 'boolean'); 201 | } 202 | 203 | /** 204 | * Gets the detailed evaluation results of a number toggle. 205 | * @param key toggle key 206 | * @param user user to be evaluated 207 | * @param defaultValue default return value 208 | */ 209 | public numberDetail(key: string, user: FPUser, defaultValue: number): FPToggleDetail { 210 | return this.toggleDetail(key, user, defaultValue, 'number'); 211 | } 212 | 213 | /** 214 | * Gets the detailed evaluation results of a string toggle. 215 | * @param key toggle key 216 | * @param user user to be evaluated 217 | * @param defaultValue default return value 218 | */ 219 | public stringDetail(key: string, user: FPUser, defaultValue: string): FPToggleDetail { 220 | return this.toggleDetail(key, user, defaultValue, 'string'); 221 | } 222 | 223 | /** 224 | * Gets the detailed evaluation results of a json toggle. 225 | * @param key toggle key 226 | * @param user user to be evaluated 227 | * @param defaultValue default return value 228 | */ 229 | public jsonDetail(key: string, user: FPUser, defaultValue: object): FPToggleDetail { 230 | return this.toggleDetail(key, user, defaultValue, 'object'); 231 | } 232 | 233 | /** 234 | * Record custom events, value is optional. 235 | */ 236 | public track(name: string, user: FPUser, value?: unknown): void { 237 | this._eventRecorder.recordTrackEvent({ 238 | kind: 'custom', 239 | name, 240 | time: Date.now(), 241 | value, 242 | user: user.key, 243 | }); 244 | } 245 | 246 | private toggleDetail(key: string, user: FPUser, defaultValue: any, valueType: ToggleValueType): FPToggleDetail { 247 | if (!this._repository.initialized) { 248 | return { 249 | value: defaultValue, 250 | ruleIndex: null, 251 | variationIndex: null, 252 | version: null, 253 | reason: 'not initialized' 254 | } as FPToggleDetail; 255 | } 256 | const toggle = this._repository.getToggle(key); 257 | if (toggle === undefined) { 258 | return { 259 | value: defaultValue, 260 | ruleIndex: null, 261 | variationIndex: null, 262 | version: null, 263 | reason: `toggle '${key}' not exist.` 264 | } as FPToggleDetail; 265 | } 266 | 267 | const segments = this._repository.segments; 268 | const toggles = this._repository.toggles; 269 | const result = toggle.eval(user, toggles, segments, defaultValue, this._prerequisiteMaxDeep); 270 | 271 | if (typeof result.value === valueType) { 272 | const timestamp = Date.now(); 273 | 274 | this._eventRecorder.recordAccessEvent({ 275 | time: timestamp, 276 | key: key, 277 | value: result.value, 278 | index: result.variationIndex ?? -1, 279 | version: result.version ?? 0, 280 | reason: result.reason 281 | }); 282 | 283 | if (toggle.trackAccessEvents) { 284 | this._eventRecorder.recordTrackEvent({ 285 | kind: 'access', 286 | key: key, 287 | user: user.key, 288 | value: result.value, 289 | variationIndex: result.variationIndex ?? -1, 290 | version: result.version ?? 0, 291 | time: timestamp, 292 | ruleIndex: result.ruleIndex ?? null, 293 | }); 294 | } 295 | 296 | if (timestamp <= this._repository.debugUntilTime) { 297 | this._eventRecorder.recordTrackEvent({ 298 | kind: 'debug', 299 | key: key, 300 | user: user.key, 301 | userDetail: user, 302 | value: result.value, 303 | variationIndex: result.variationIndex ?? -1, 304 | version: result.version ?? 0, 305 | time: timestamp, 306 | ruleIndex: result.ruleIndex ?? null, 307 | }); 308 | } 309 | 310 | return result; 311 | } else { 312 | return { 313 | value: defaultValue, 314 | ruleIndex: null, 315 | variationIndex: null, 316 | version: null, 317 | reason: 'value type mismatch.' 318 | } as FPToggleDetail; 319 | } 320 | } 321 | 322 | private async connectSocket() { 323 | const url = new URL(this._realtimeUrl); 324 | 325 | this._logger?.info('connect socket to ' + this._realtimeUrl + ' ' + url.pathname); 326 | const socket = io(this._realtimeUrl, { transports: ['websocket'], path: url.pathname }); 327 | 328 | socket.on('connect', () => { 329 | this._logger?.info('connect socketio success'); 330 | socket.emit('register', { key: this._serverSdkKey }); 331 | }); 332 | 333 | socket.on('update', () => { 334 | this._logger?.info('socketio recv update event'); 335 | (async () => { 336 | await this._toggleSyncer.syncNow() 337 | })() 338 | }); 339 | 340 | socket.on('connect_error', (error: Error) => { 341 | this._logger?.info(`socketio error ${error.message}`); 342 | }) 343 | 344 | // this._socket = socket; 345 | } 346 | } 347 | 348 | type ToggleValueType = 'boolean' | 'number' | 'string' | 'object'; 349 | -------------------------------------------------------------------------------- /docs/assets/search.js: -------------------------------------------------------------------------------- 1 | window.searchData = JSON.parse("{\"kinds\":{\"128\":\"Class\",\"256\":\"Interface\",\"512\":\"Constructor\",\"1024\":\"Property\",\"2048\":\"Method\",\"65536\":\"Type literal\",\"262144\":\"Accessor\"},\"rows\":[{\"kind\":128,\"name\":\"FeatureProbe\",\"url\":\"classes/FeatureProbe.html\",\"classes\":\"tsd-kind-class\"},{\"kind\":512,\"name\":\"constructor\",\"url\":\"classes/FeatureProbe.html#constructor\",\"classes\":\"tsd-kind-constructor tsd-parent-kind-class\",\"parent\":\"FeatureProbe\"},{\"kind\":1024,\"name\":\"_remoteUrl\",\"url\":\"classes/FeatureProbe.html#_remoteUrl\",\"classes\":\"tsd-kind-property tsd-parent-kind-class tsd-is-private\",\"parent\":\"FeatureProbe\"},{\"kind\":1024,\"name\":\"_togglesUrl\",\"url\":\"classes/FeatureProbe.html#_togglesUrl\",\"classes\":\"tsd-kind-property tsd-parent-kind-class tsd-is-private\",\"parent\":\"FeatureProbe\"},{\"kind\":1024,\"name\":\"_eventsUrl\",\"url\":\"classes/FeatureProbe.html#_eventsUrl\",\"classes\":\"tsd-kind-property tsd-parent-kind-class tsd-is-private\",\"parent\":\"FeatureProbe\"},{\"kind\":1024,\"name\":\"_realtimeUrl\",\"url\":\"classes/FeatureProbe.html#_realtimeUrl\",\"classes\":\"tsd-kind-property tsd-parent-kind-class tsd-is-private\",\"parent\":\"FeatureProbe\"},{\"kind\":1024,\"name\":\"_serverSdkKey\",\"url\":\"classes/FeatureProbe.html#_serverSdkKey\",\"classes\":\"tsd-kind-property tsd-parent-kind-class tsd-is-private\",\"parent\":\"FeatureProbe\"},{\"kind\":1024,\"name\":\"_refreshInterval\",\"url\":\"classes/FeatureProbe.html#_refreshInterval\",\"classes\":\"tsd-kind-property tsd-parent-kind-class tsd-is-private\",\"parent\":\"FeatureProbe\"},{\"kind\":1024,\"name\":\"_eventRecorder\",\"url\":\"classes/FeatureProbe.html#_eventRecorder\",\"classes\":\"tsd-kind-property tsd-parent-kind-class tsd-is-private\",\"parent\":\"FeatureProbe\"},{\"kind\":1024,\"name\":\"_toggleSyncer\",\"url\":\"classes/FeatureProbe.html#_toggleSyncer\",\"classes\":\"tsd-kind-property tsd-parent-kind-class tsd-is-private\",\"parent\":\"FeatureProbe\"},{\"kind\":1024,\"name\":\"_repository\",\"url\":\"classes/FeatureProbe.html#_repository\",\"classes\":\"tsd-kind-property tsd-parent-kind-class tsd-is-private\",\"parent\":\"FeatureProbe\"},{\"kind\":1024,\"name\":\"_prerequisiteMaxDeep\",\"url\":\"classes/FeatureProbe.html#_prerequisiteMaxDeep\",\"classes\":\"tsd-kind-property tsd-parent-kind-class tsd-is-private\",\"parent\":\"FeatureProbe\"},{\"kind\":1024,\"name\":\"_logger\",\"url\":\"classes/FeatureProbe.html#_logger\",\"classes\":\"tsd-kind-property tsd-parent-kind-class tsd-is-private\",\"parent\":\"FeatureProbe\"},{\"kind\":262144,\"name\":\"initialized\",\"url\":\"classes/FeatureProbe.html#initialized\",\"classes\":\"tsd-kind-accessor tsd-parent-kind-class\",\"parent\":\"FeatureProbe\"},{\"kind\":2048,\"name\":\"start\",\"url\":\"classes/FeatureProbe.html#start\",\"classes\":\"tsd-kind-method tsd-parent-kind-class\",\"parent\":\"FeatureProbe\"},{\"kind\":2048,\"name\":\"close\",\"url\":\"classes/FeatureProbe.html#close\",\"classes\":\"tsd-kind-method tsd-parent-kind-class\",\"parent\":\"FeatureProbe\"},{\"kind\":2048,\"name\":\"flush\",\"url\":\"classes/FeatureProbe.html#flush\",\"classes\":\"tsd-kind-method tsd-parent-kind-class\",\"parent\":\"FeatureProbe\"},{\"kind\":2048,\"name\":\"booleanValue\",\"url\":\"classes/FeatureProbe.html#booleanValue\",\"classes\":\"tsd-kind-method tsd-parent-kind-class\",\"parent\":\"FeatureProbe\"},{\"kind\":2048,\"name\":\"numberValue\",\"url\":\"classes/FeatureProbe.html#numberValue\",\"classes\":\"tsd-kind-method tsd-parent-kind-class\",\"parent\":\"FeatureProbe\"},{\"kind\":2048,\"name\":\"stringValue\",\"url\":\"classes/FeatureProbe.html#stringValue\",\"classes\":\"tsd-kind-method tsd-parent-kind-class\",\"parent\":\"FeatureProbe\"},{\"kind\":2048,\"name\":\"jsonValue\",\"url\":\"classes/FeatureProbe.html#jsonValue\",\"classes\":\"tsd-kind-method tsd-parent-kind-class\",\"parent\":\"FeatureProbe\"},{\"kind\":2048,\"name\":\"booleanDetail\",\"url\":\"classes/FeatureProbe.html#booleanDetail\",\"classes\":\"tsd-kind-method tsd-parent-kind-class\",\"parent\":\"FeatureProbe\"},{\"kind\":2048,\"name\":\"numberDetail\",\"url\":\"classes/FeatureProbe.html#numberDetail\",\"classes\":\"tsd-kind-method tsd-parent-kind-class\",\"parent\":\"FeatureProbe\"},{\"kind\":2048,\"name\":\"stringDetail\",\"url\":\"classes/FeatureProbe.html#stringDetail\",\"classes\":\"tsd-kind-method tsd-parent-kind-class\",\"parent\":\"FeatureProbe\"},{\"kind\":2048,\"name\":\"jsonDetail\",\"url\":\"classes/FeatureProbe.html#jsonDetail\",\"classes\":\"tsd-kind-method tsd-parent-kind-class\",\"parent\":\"FeatureProbe\"},{\"kind\":2048,\"name\":\"track\",\"url\":\"classes/FeatureProbe.html#track\",\"classes\":\"tsd-kind-method tsd-parent-kind-class\",\"parent\":\"FeatureProbe\"},{\"kind\":2048,\"name\":\"toggleDetail\",\"url\":\"classes/FeatureProbe.html#toggleDetail\",\"classes\":\"tsd-kind-method tsd-parent-kind-class tsd-is-private\",\"parent\":\"FeatureProbe\"},{\"kind\":2048,\"name\":\"connectSocket\",\"url\":\"classes/FeatureProbe.html#connectSocket\",\"classes\":\"tsd-kind-method tsd-parent-kind-class tsd-is-private\",\"parent\":\"FeatureProbe\"},{\"kind\":128,\"name\":\"FPUser\",\"url\":\"classes/FPUser.html\",\"classes\":\"tsd-kind-class\"},{\"kind\":512,\"name\":\"constructor\",\"url\":\"classes/FPUser.html#constructor\",\"classes\":\"tsd-kind-constructor tsd-parent-kind-class\",\"parent\":\"FPUser\"},{\"kind\":1024,\"name\":\"_key\",\"url\":\"classes/FPUser.html#_key\",\"classes\":\"tsd-kind-property tsd-parent-kind-class tsd-is-private\",\"parent\":\"FPUser\"},{\"kind\":1024,\"name\":\"_attrs\",\"url\":\"classes/FPUser.html#_attrs\",\"classes\":\"tsd-kind-property tsd-parent-kind-class tsd-is-private\",\"parent\":\"FPUser\"},{\"kind\":65536,\"name\":\"__type\",\"url\":\"classes/FPUser.html#__type\",\"classes\":\"tsd-kind-type-literal tsd-parent-kind-class\",\"parent\":\"FPUser\"},{\"kind\":262144,\"name\":\"key\",\"url\":\"classes/FPUser.html#key\",\"classes\":\"tsd-kind-accessor tsd-parent-kind-class\",\"parent\":\"FPUser\"},{\"kind\":262144,\"name\":\"attrs\",\"url\":\"classes/FPUser.html#attrs\",\"classes\":\"tsd-kind-accessor tsd-parent-kind-class\",\"parent\":\"FPUser\"},{\"kind\":65536,\"name\":\"__type\",\"url\":\"classes/FPUser.html#attrs.attrs-1.__type-1\",\"classes\":\"tsd-kind-type-literal\",\"parent\":\"FPUser.attrs.attrs\"},{\"kind\":2048,\"name\":\"stableRollout\",\"url\":\"classes/FPUser.html#stableRollout\",\"classes\":\"tsd-kind-method tsd-parent-kind-class\",\"parent\":\"FPUser\"},{\"kind\":2048,\"name\":\"with\",\"url\":\"classes/FPUser.html#with\",\"classes\":\"tsd-kind-method tsd-parent-kind-class\",\"parent\":\"FPUser\"},{\"kind\":2048,\"name\":\"extendAttrs\",\"url\":\"classes/FPUser.html#extendAttrs\",\"classes\":\"tsd-kind-method tsd-parent-kind-class\",\"parent\":\"FPUser\"},{\"kind\":2048,\"name\":\"getAttr\",\"url\":\"classes/FPUser.html#getAttr\",\"classes\":\"tsd-kind-method tsd-parent-kind-class\",\"parent\":\"FPUser\"},{\"kind\":256,\"name\":\"FPConfig\",\"url\":\"interfaces/FPConfig.html\",\"classes\":\"tsd-kind-interface\"},{\"kind\":1024,\"name\":\"serverSdkKey\",\"url\":\"interfaces/FPConfig.html#serverSdkKey\",\"classes\":\"tsd-kind-property tsd-parent-kind-interface\",\"parent\":\"FPConfig\"},{\"kind\":1024,\"name\":\"remoteUrl\",\"url\":\"interfaces/FPConfig.html#remoteUrl\",\"classes\":\"tsd-kind-property tsd-parent-kind-interface\",\"parent\":\"FPConfig\"},{\"kind\":1024,\"name\":\"togglesUrl\",\"url\":\"interfaces/FPConfig.html#togglesUrl\",\"classes\":\"tsd-kind-property tsd-parent-kind-interface\",\"parent\":\"FPConfig\"},{\"kind\":1024,\"name\":\"eventsUrl\",\"url\":\"interfaces/FPConfig.html#eventsUrl\",\"classes\":\"tsd-kind-property tsd-parent-kind-interface\",\"parent\":\"FPConfig\"},{\"kind\":1024,\"name\":\"realtimeUrl\",\"url\":\"interfaces/FPConfig.html#realtimeUrl\",\"classes\":\"tsd-kind-property tsd-parent-kind-interface\",\"parent\":\"FPConfig\"},{\"kind\":1024,\"name\":\"refreshInterval\",\"url\":\"interfaces/FPConfig.html#refreshInterval\",\"classes\":\"tsd-kind-property tsd-parent-kind-interface\",\"parent\":\"FPConfig\"},{\"kind\":1024,\"name\":\"prerequisiteMaxDeep\",\"url\":\"interfaces/FPConfig.html#prerequisiteMaxDeep\",\"classes\":\"tsd-kind-property tsd-parent-kind-interface\",\"parent\":\"FPConfig\"},{\"kind\":1024,\"name\":\"logger\",\"url\":\"interfaces/FPConfig.html#logger\",\"classes\":\"tsd-kind-property tsd-parent-kind-interface\",\"parent\":\"FPConfig\"},{\"kind\":256,\"name\":\"FPToggleDetail\",\"url\":\"interfaces/FPToggleDetail.html\",\"classes\":\"tsd-kind-interface\"},{\"kind\":1024,\"name\":\"value\",\"url\":\"interfaces/FPToggleDetail.html#value\",\"classes\":\"tsd-kind-property tsd-parent-kind-interface\",\"parent\":\"FPToggleDetail\"},{\"kind\":1024,\"name\":\"ruleIndex\",\"url\":\"interfaces/FPToggleDetail.html#ruleIndex\",\"classes\":\"tsd-kind-property tsd-parent-kind-interface\",\"parent\":\"FPToggleDetail\"},{\"kind\":1024,\"name\":\"variationIndex\",\"url\":\"interfaces/FPToggleDetail.html#variationIndex\",\"classes\":\"tsd-kind-property tsd-parent-kind-interface\",\"parent\":\"FPToggleDetail\"},{\"kind\":1024,\"name\":\"version\",\"url\":\"interfaces/FPToggleDetail.html#version\",\"classes\":\"tsd-kind-property tsd-parent-kind-interface\",\"parent\":\"FPToggleDetail\"},{\"kind\":1024,\"name\":\"reason\",\"url\":\"interfaces/FPToggleDetail.html#reason\",\"classes\":\"tsd-kind-property tsd-parent-kind-interface\",\"parent\":\"FPToggleDetail\"}],\"index\":{\"version\":\"2.3.9\",\"fields\":[\"name\",\"comment\"],\"fieldVectors\":[[\"name/0\",[0,36.199]],[\"comment/0\",[]],[\"name/1\",[1,31.091]],[\"comment/1\",[]],[\"name/2\",[2,36.199]],[\"comment/2\",[]],[\"name/3\",[3,36.199]],[\"comment/3\",[]],[\"name/4\",[4,36.199]],[\"comment/4\",[]],[\"name/5\",[5,36.199]],[\"comment/5\",[]],[\"name/6\",[6,36.199]],[\"comment/6\",[]],[\"name/7\",[7,36.199]],[\"comment/7\",[]],[\"name/8\",[8,36.199]],[\"comment/8\",[]],[\"name/9\",[9,36.199]],[\"comment/9\",[]],[\"name/10\",[10,36.199]],[\"comment/10\",[]],[\"name/11\",[11,36.199]],[\"comment/11\",[]],[\"name/12\",[12,36.199]],[\"comment/12\",[]],[\"name/13\",[13,36.199]],[\"comment/13\",[]],[\"name/14\",[14,36.199]],[\"comment/14\",[]],[\"name/15\",[15,36.199]],[\"comment/15\",[]],[\"name/16\",[16,36.199]],[\"comment/16\",[]],[\"name/17\",[17,36.199]],[\"comment/17\",[]],[\"name/18\",[18,36.199]],[\"comment/18\",[]],[\"name/19\",[19,36.199]],[\"comment/19\",[]],[\"name/20\",[20,36.199]],[\"comment/20\",[]],[\"name/21\",[21,36.199]],[\"comment/21\",[]],[\"name/22\",[22,36.199]],[\"comment/22\",[]],[\"name/23\",[23,36.199]],[\"comment/23\",[]],[\"name/24\",[24,36.199]],[\"comment/24\",[]],[\"name/25\",[25,36.199]],[\"comment/25\",[]],[\"name/26\",[26,36.199]],[\"comment/26\",[]],[\"name/27\",[27,36.199]],[\"comment/27\",[]],[\"name/28\",[28,36.199]],[\"comment/28\",[]],[\"name/29\",[1,31.091]],[\"comment/29\",[]],[\"name/30\",[29,36.199]],[\"comment/30\",[]],[\"name/31\",[30,36.199]],[\"comment/31\",[]],[\"name/32\",[31,31.091]],[\"comment/32\",[]],[\"name/33\",[32,36.199]],[\"comment/33\",[]],[\"name/34\",[33,36.199]],[\"comment/34\",[]],[\"name/35\",[31,31.091]],[\"comment/35\",[]],[\"name/36\",[34,36.199]],[\"comment/36\",[]],[\"name/37\",[35,36.199]],[\"comment/37\",[]],[\"name/38\",[36,36.199]],[\"comment/38\",[]],[\"name/39\",[37,36.199]],[\"comment/39\",[]],[\"name/40\",[38,36.199]],[\"comment/40\",[]],[\"name/41\",[39,36.199]],[\"comment/41\",[]],[\"name/42\",[40,36.199]],[\"comment/42\",[]],[\"name/43\",[41,36.199]],[\"comment/43\",[]],[\"name/44\",[42,36.199]],[\"comment/44\",[]],[\"name/45\",[43,36.199]],[\"comment/45\",[]],[\"name/46\",[44,36.199]],[\"comment/46\",[]],[\"name/47\",[45,36.199]],[\"comment/47\",[]],[\"name/48\",[46,36.199]],[\"comment/48\",[]],[\"name/49\",[47,36.199]],[\"comment/49\",[]],[\"name/50\",[48,36.199]],[\"comment/50\",[]],[\"name/51\",[49,36.199]],[\"comment/51\",[]],[\"name/52\",[50,36.199]],[\"comment/52\",[]],[\"name/53\",[51,36.199]],[\"comment/53\",[]],[\"name/54\",[52,36.199]],[\"comment/54\",[]]],\"invertedIndex\":[[\"__type\",{\"_index\":31,\"name\":{\"32\":{},\"35\":{}},\"comment\":{}}],[\"_attrs\",{\"_index\":30,\"name\":{\"31\":{}},\"comment\":{}}],[\"_eventrecorder\",{\"_index\":8,\"name\":{\"8\":{}},\"comment\":{}}],[\"_eventsurl\",{\"_index\":4,\"name\":{\"4\":{}},\"comment\":{}}],[\"_key\",{\"_index\":29,\"name\":{\"30\":{}},\"comment\":{}}],[\"_logger\",{\"_index\":12,\"name\":{\"12\":{}},\"comment\":{}}],[\"_prerequisitemaxdeep\",{\"_index\":11,\"name\":{\"11\":{}},\"comment\":{}}],[\"_realtimeurl\",{\"_index\":5,\"name\":{\"5\":{}},\"comment\":{}}],[\"_refreshinterval\",{\"_index\":7,\"name\":{\"7\":{}},\"comment\":{}}],[\"_remoteurl\",{\"_index\":2,\"name\":{\"2\":{}},\"comment\":{}}],[\"_repository\",{\"_index\":10,\"name\":{\"10\":{}},\"comment\":{}}],[\"_serversdkkey\",{\"_index\":6,\"name\":{\"6\":{}},\"comment\":{}}],[\"_togglesurl\",{\"_index\":3,\"name\":{\"3\":{}},\"comment\":{}}],[\"_togglesyncer\",{\"_index\":9,\"name\":{\"9\":{}},\"comment\":{}}],[\"attrs\",{\"_index\":33,\"name\":{\"34\":{}},\"comment\":{}}],[\"booleandetail\",{\"_index\":21,\"name\":{\"21\":{}},\"comment\":{}}],[\"booleanvalue\",{\"_index\":17,\"name\":{\"17\":{}},\"comment\":{}}],[\"close\",{\"_index\":15,\"name\":{\"15\":{}},\"comment\":{}}],[\"connectsocket\",{\"_index\":27,\"name\":{\"27\":{}},\"comment\":{}}],[\"constructor\",{\"_index\":1,\"name\":{\"1\":{},\"29\":{}},\"comment\":{}}],[\"eventsurl\",{\"_index\":42,\"name\":{\"44\":{}},\"comment\":{}}],[\"extendattrs\",{\"_index\":36,\"name\":{\"38\":{}},\"comment\":{}}],[\"featureprobe\",{\"_index\":0,\"name\":{\"0\":{}},\"comment\":{}}],[\"flush\",{\"_index\":16,\"name\":{\"16\":{}},\"comment\":{}}],[\"fpconfig\",{\"_index\":38,\"name\":{\"40\":{}},\"comment\":{}}],[\"fptoggledetail\",{\"_index\":47,\"name\":{\"49\":{}},\"comment\":{}}],[\"fpuser\",{\"_index\":28,\"name\":{\"28\":{}},\"comment\":{}}],[\"getattr\",{\"_index\":37,\"name\":{\"39\":{}},\"comment\":{}}],[\"initialized\",{\"_index\":13,\"name\":{\"13\":{}},\"comment\":{}}],[\"jsondetail\",{\"_index\":24,\"name\":{\"24\":{}},\"comment\":{}}],[\"jsonvalue\",{\"_index\":20,\"name\":{\"20\":{}},\"comment\":{}}],[\"key\",{\"_index\":32,\"name\":{\"33\":{}},\"comment\":{}}],[\"logger\",{\"_index\":46,\"name\":{\"48\":{}},\"comment\":{}}],[\"numberdetail\",{\"_index\":22,\"name\":{\"22\":{}},\"comment\":{}}],[\"numbervalue\",{\"_index\":18,\"name\":{\"18\":{}},\"comment\":{}}],[\"prerequisitemaxdeep\",{\"_index\":45,\"name\":{\"47\":{}},\"comment\":{}}],[\"realtimeurl\",{\"_index\":43,\"name\":{\"45\":{}},\"comment\":{}}],[\"reason\",{\"_index\":52,\"name\":{\"54\":{}},\"comment\":{}}],[\"refreshinterval\",{\"_index\":44,\"name\":{\"46\":{}},\"comment\":{}}],[\"remoteurl\",{\"_index\":40,\"name\":{\"42\":{}},\"comment\":{}}],[\"ruleindex\",{\"_index\":49,\"name\":{\"51\":{}},\"comment\":{}}],[\"serversdkkey\",{\"_index\":39,\"name\":{\"41\":{}},\"comment\":{}}],[\"stablerollout\",{\"_index\":34,\"name\":{\"36\":{}},\"comment\":{}}],[\"start\",{\"_index\":14,\"name\":{\"14\":{}},\"comment\":{}}],[\"stringdetail\",{\"_index\":23,\"name\":{\"23\":{}},\"comment\":{}}],[\"stringvalue\",{\"_index\":19,\"name\":{\"19\":{}},\"comment\":{}}],[\"toggledetail\",{\"_index\":26,\"name\":{\"26\":{}},\"comment\":{}}],[\"togglesurl\",{\"_index\":41,\"name\":{\"43\":{}},\"comment\":{}}],[\"track\",{\"_index\":25,\"name\":{\"25\":{}},\"comment\":{}}],[\"value\",{\"_index\":48,\"name\":{\"50\":{}},\"comment\":{}}],[\"variationindex\",{\"_index\":50,\"name\":{\"52\":{}},\"comment\":{}}],[\"version\",{\"_index\":51,\"name\":{\"53\":{}},\"comment\":{}}],[\"with\",{\"_index\":35,\"name\":{\"37\":{}},\"comment\":{}}]],\"pipeline\":[]}}"); -------------------------------------------------------------------------------- /docs/interfaces/FPToggleDetail.html: -------------------------------------------------------------------------------- 1 | FPToggleDetail | FeatureProbe Server Side SDK for Node.js (2.2.0)
2 |
3 | 10 |
11 |
12 |
13 |
14 | 17 |

Interface FPToggleDetail

18 |
19 |

Hierarchy

20 |
    21 |
  • FPToggleDetail
24 |
25 |
26 |
27 | 28 |
29 |
30 |

Properties

31 |
reason 32 | ruleIndex 33 | value 34 | variationIndex 35 | version 36 |
37 |
38 |

Properties

39 |
40 | 41 |
reason: null | string
42 |

The failed reason.

43 |
46 |
47 | 48 |
ruleIndex: null | number
49 |

The index of the matching rule.

50 |
53 |
54 | 55 |
value: any
56 |

Return value of a toggle for the current user.

57 |
60 |
61 | 62 |
variationIndex: null | number
63 |

The index of the matching variation.

64 |
67 |
68 | 69 |
version: null | number
70 |

The version of the toggle.

71 |
74 |
104 |
105 |

Generated using TypeDoc

106 |
-------------------------------------------------------------------------------- /test/Evaluate.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 FeatureProbe 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { saltHash, Repository, Condition, Split } from '../src/Evaluate'; 18 | import { FPUser } from '../src'; 19 | 20 | const repo = new Repository(require('./fixtures/repo.json')); 21 | 22 | test('salt hash', () => { 23 | expect(saltHash('key', 'salt', 10000)).toBe(2647); 24 | }); 25 | 26 | test('[is in] segment condition match', () => { 27 | const toggle = repo.toggles['json_toggle']; 28 | const user = new FPUser().with('city', '4'); 29 | const detail = toggle.eval(user, repo.toggles, repo.segments, {}); 30 | expect(detail.value['variation_1']).toBe('v2'); 31 | }); 32 | 33 | test('[is not in] segment condition match', () => { 34 | const user = new FPUser().with('city', '100'); 35 | const toggle = repo.toggles['not_in_segment']; 36 | const detail = toggle.eval(user, repo.toggles, repo.segments, {}); 37 | expect(detail.value['not_in']).toBe(true); 38 | }); 39 | 40 | test('not match in segment condition', () => { 41 | const user = new FPUser().with('city', '100'); 42 | const toggle = repo.toggles['json_toggle']; 43 | const detail = toggle.eval(user, repo.toggles, repo.segments, {}); 44 | expect(detail.reason).toBe('default.'); 45 | }); 46 | 47 | test('no segments', () => { 48 | const c = new Condition({ 49 | type: 'segment', 50 | subject: 'city?' 51 | // objects is undefined or empty 52 | }); 53 | const user = new FPUser().with('city', '100'); 54 | 55 | c.predicate = 'is in'; 56 | expect(c.meet(user, undefined)).toBeFalsy(); 57 | c.predicate = 'is not in'; 58 | expect(c.meet(user, undefined)).toBeFalsy(); 59 | }); 60 | 61 | test('segment invalid predicate', () => { 62 | const c = new Condition({ 63 | type: 'segment', 64 | subject: 'city?' 65 | // objects is undefined or empty 66 | }); 67 | const user = new FPUser().stableRollout('key111') 68 | .with('city', '100'); 69 | 70 | c.predicate = 'invalid'; 71 | expect(c.meet(user)).toBeFalsy(); 72 | }); 73 | 74 | test('multiple conditions', () => { 75 | const toggle = repo.toggles['multi_condition_toggle']; 76 | let user, detail; 77 | 78 | user = new FPUser().stableRollout('key') 79 | .with('city', '1'); 80 | detail = toggle.eval(user, repo.toggles, repo.segments, null); 81 | expect(detail.reason).toBe('default. warning: user with key \'key\' does not have attribute name \'os\''); 82 | 83 | user = new FPUser().stableRollout('key') 84 | .with('os', 'linux'); 85 | detail = toggle.eval(user, repo.toggles, repo.segments, null); 86 | expect(detail.reason).toBe('default. warning: user with key \'key\' does not have attribute name \'city\''); 87 | }); 88 | 89 | test('toggle disabled', () => { 90 | const toggle = repo.toggles['disabled_toggle']; 91 | const user = new FPUser().with('city', '100'); 92 | const detail = toggle.eval(user, repo.toggles, repo.segments, null); 93 | expect(detail.reason).toBe('disabled.'); 94 | }); 95 | 96 | test('[is one of] string condition match', () => { 97 | const condition = new Condition({ 98 | type: 'string', 99 | predicate: 'is one of', 100 | subject: 'name', 101 | objects: ['hello', 'world'] 102 | }); 103 | const user = new FPUser().with('name', 'world'); 104 | expect(condition.meet(user)).toBeTruthy(); 105 | }); 106 | 107 | test('[is not any of] string condition match', () => { 108 | const condition = new Condition({ 109 | type: 'string', 110 | predicate: 'is not any of', 111 | subject: 'name', 112 | objects: ['hello', 'world'] 113 | }); 114 | 115 | const user = new FPUser().with('name', 'no'); 116 | expect(condition.meet(user)).toBeTruthy(); 117 | const user1 = new FPUser(); 118 | expect(condition.meet(user1)).toBeFalsy(); 119 | }); 120 | 121 | test('[ends with] string condition match', () => { 122 | const condition = new Condition({ 123 | type: 'string', 124 | predicate: 'ends with', 125 | subject: 'name', 126 | objects: ['hello', 'world'] 127 | }); 128 | const user = new FPUser().with('name', 'the world'); 129 | expect(condition.meet(user)).toBeTruthy(); 130 | user.with('name', 'the word'); 131 | expect(condition.meet(user)).toBeFalsy(); 132 | }); 133 | 134 | test('[does not end with] string condition match', () => { 135 | const condition = new Condition({ 136 | type: 'string', 137 | predicate: 'does not end with', 138 | subject: 'name', 139 | objects: ['hello', 'world'] 140 | }); 141 | const user = new FPUser().with('name', 'world111'); 142 | expect(condition.meet(user)).toBeTruthy(); 143 | user.with('name', 'my world'); 144 | expect(condition.meet(user)).toBeFalsy(); 145 | }); 146 | 147 | test('[starts with] string condition match', () => { 148 | const condition = new Condition({ 149 | type: 'string', 150 | predicate: 'starts with', 151 | subject: 'name', 152 | objects: ['hello', 'world'] 153 | }); 154 | const user = new FPUser().with('name', 'world!'); 155 | expect(condition.meet(user)).toBeTruthy(); 156 | user.with('name', 'the word'); 157 | expect(condition.meet(user)).toBeFalsy(); 158 | }); 159 | 160 | test('[does not start with] string condition match', () => { 161 | const condition = new Condition({ 162 | type: 'string', 163 | predicate: 'does not start with', 164 | subject: 'name', 165 | objects: ['hello', 'world'] 166 | }); 167 | const user = new FPUser().with('name', '1world111'); 168 | expect(condition.meet(user)).toBeTruthy(); 169 | user.with('name', 'world1'); 170 | expect(condition.meet(user)).toBeFalsy(); 171 | }); 172 | 173 | test('[contains] string condition match', () => { 174 | const condition = new Condition({ 175 | type: 'string', 176 | predicate: 'starts with', 177 | subject: 'name', 178 | objects: ['hello', 'world'] 179 | }); 180 | const user = new FPUser().with('name', 'world!'); 181 | expect(condition.meet(user)).toBeTruthy(); 182 | user.with('name', 'ord'); 183 | expect(condition.meet(user)).toBeFalsy(); 184 | }); 185 | 186 | test('[does not contain] string condition match', () => { 187 | const condition = new Condition({ 188 | type: 'string', 189 | predicate: 'does not contain', 190 | subject: 'name', 191 | objects: ['hello', 'world'] 192 | }); 193 | const user = new FPUser().with('name', '1world111'); 194 | expect(condition.meet(user)).toBeFalsy(); 195 | user.with('name', 'the wor1d'); 196 | expect(condition.meet(user)).toBeTruthy(); 197 | }); 198 | 199 | test('[matches regex] string condition match', () => { 200 | const condition = new Condition({ 201 | type: 'string', 202 | predicate: 'matches regex', 203 | subject: 'name', 204 | objects: ['hello\\d', 'world.+', '^strict$'] 205 | }); 206 | const user = new FPUser().with('name', '1world111'); 207 | expect(condition.meet(user)).toBeTruthy(); 208 | user.with('name', 'a hello1\n'); 209 | expect(condition.meet(user)).toBeTruthy(); 210 | user.with('name', '2world'); 211 | expect(condition.meet(user)).toBeFalsy(); 212 | user.with('name', ' strict '); 213 | expect(condition.meet(user)).toBeFalsy(); 214 | 215 | // invalid regex 216 | condition.objects = ['\\\\\\']; 217 | user.with('name', '\\\\\\'); 218 | expect(condition.meet(user)).toBeFalsy(); 219 | }); 220 | 221 | test('[does not match regex] string condition match', () => { 222 | const condition = new Condition({ 223 | type: 'string', 224 | predicate: 'does not match regex', 225 | subject: 'name', 226 | objects: ['hello\\d', 'world.+', '^strict$'] 227 | }); 228 | const user = new FPUser().with('name', '1world'); 229 | expect(condition.meet(user)).toBeTruthy(); 230 | }); 231 | 232 | test('[before] datetime condition match', () => { 233 | const now = Date.now() / 1000; 234 | const condition = new Condition({ 235 | type: 'datetime', 236 | predicate: 'before', 237 | subject: 'created', 238 | objects: [( now + 10 ).toString()] 239 | }); 240 | 241 | const user = new FPUser(); 242 | expect(condition.meet(user)).toBeTruthy(); 243 | 244 | user.with('created', now.toString()); 245 | expect(condition.meet(user)).toBeTruthy(); 246 | 247 | user.with('created', ( now + 10 ).toString()); 248 | expect(condition.meet(user)).toBeFalsy(); 249 | }); 250 | 251 | test('[after] datetime condition match', () => { 252 | const now = Date.now() / 1000; 253 | const condition = new Condition({ 254 | type: 'datetime', 255 | predicate: 'after', 256 | subject: 'created', 257 | objects: [now.toString()] 258 | }); 259 | 260 | const user = new FPUser(); 261 | expect(condition.meet(user)).toBeTruthy(); 262 | 263 | user.with('created', now.toString()); 264 | expect(condition.meet(user)).toBeTruthy(); 265 | 266 | user.with('created', ( now + 1 ).toString()); 267 | expect(condition.meet(user)).toBeTruthy(); 268 | 269 | user.with('created', ( now - 10 ).toString()); 270 | expect(condition.meet(user)).toBeFalsy(); 271 | }); 272 | 273 | test('datetime condition invalid custom value', () => { 274 | const now = Date.now() / 1000; 275 | const condition = new Condition({ 276 | type: 'datetime', 277 | predicate: 'before', 278 | subject: 'created', 279 | objects: [now.toString()] 280 | }); 281 | 282 | const user = new FPUser().with('created', 'foo'); 283 | expect(condition.meet(user)).toBeFalsy(); 284 | 285 | condition.objects = ['foo']; 286 | user.with('created', now.toString()); 287 | expect(condition.meet(user)).toBeFalsy(); 288 | }); 289 | 290 | test('[=] number condition match', () => { 291 | const condition = new Condition({ 292 | type: 'number', 293 | predicate: '=', 294 | subject: 'count', 295 | objects: ['1', '2', '5'] 296 | }); 297 | 298 | const user = new FPUser().with('count', '5'); 299 | expect(condition.meet(user)).toBeTruthy(); 300 | 301 | user.with('count', '4'); 302 | expect(condition.meet(user)).toBeFalsy(); 303 | }); 304 | 305 | test('[!=] number condition match', () => { 306 | const condition = new Condition({ 307 | type: 'number', 308 | predicate: '!=', 309 | subject: 'count', 310 | objects: ['1', '2', '5'] 311 | }); 312 | 313 | const user = new FPUser().with('count', '5'); 314 | expect(condition.meet(user)).toBeFalsy(); 315 | 316 | user.with('count', '4'); 317 | expect(condition.meet(user)).toBeTruthy(); 318 | }); 319 | 320 | test('[>] number condition match', () => { 321 | const condition = new Condition({ 322 | type: 'number', 323 | predicate: '>', 324 | subject: 'count', 325 | objects: ['1.e0', '2', '5'] 326 | }); 327 | 328 | const user = new FPUser().with('count', '1'); 329 | expect(condition.meet(user)).toBeFalsy(); 330 | 331 | user.with('count', '\n1.000001 '); 332 | expect(condition.meet(user)).toBeTruthy(); 333 | }); 334 | 335 | test('[>=] number condition match', () => { 336 | const condition = new Condition({ 337 | type: 'number', 338 | predicate: '>=', 339 | subject: 'count', 340 | objects: ['1.e0', '2', '5'] 341 | }); 342 | 343 | const user = new FPUser().with('count', '0.9'); 344 | expect(condition.meet(user)).toBeFalsy(); 345 | 346 | user.with('count', '10e-1'); 347 | expect(condition.meet(user)).toBeTruthy(); 348 | }); 349 | 350 | test('[<] number condition match', () => { 351 | const condition = new Condition({ 352 | type: 'number', 353 | predicate: '<', 354 | subject: 'count', 355 | objects: ['1.e0', '2', '5'] 356 | }); 357 | 358 | const user = new FPUser().with('count', '3'); 359 | expect(condition.meet(user)).toBeTruthy(); 360 | 361 | user.with('count', '7'); 362 | expect(condition.meet(user)).toBeFalsy(); 363 | }); 364 | 365 | test('[<=] number condition match', () => { 366 | const condition = new Condition({ 367 | type: 'number', 368 | predicate: '<=', 369 | subject: 'count', 370 | objects: ['1.e0', '2', '5'] 371 | }); 372 | 373 | const user = new FPUser().with('count', '1'); 374 | expect(condition.meet(user)).toBeTruthy(); 375 | 376 | user.with('count', '10e-100'); 377 | expect(condition.meet(user)).toBeTruthy(); 378 | 379 | user.with('count', '10e100'); 380 | expect(condition.meet(user)).toBeFalsy(); 381 | }); 382 | 383 | test('invalid number condition', () => { 384 | const condition = new Condition({ 385 | type: 'number', 386 | predicate: '?=', 387 | subject: 'count', 388 | objects: ['1.e0', '2', '5'] 389 | }); 390 | 391 | // invalid predicate 392 | const user = new FPUser().with('count', '1'); 393 | expect(condition.meet(user)).toBeFalsy(); 394 | 395 | // invalid customValue 396 | condition.predicate = '='; 397 | user.with('count', 'foo'); 398 | expect(condition.meet(user)).toBeFalsy(); 399 | 400 | // invalid customValue + object 401 | condition.objects = ['foo', 'bar']; 402 | expect(condition.meet(user)).toBeFalsy(); 403 | 404 | // invalid object 405 | user.with('count', '2'); 406 | expect(condition.meet(user)).toBeFalsy(); 407 | 408 | condition.objects = ['foo', 'bar', '2']; 409 | expect(condition.meet(user)).toBeTruthy(); 410 | }); 411 | 412 | test('[=] semver condition match', () => { 413 | const condition = new Condition({ 414 | type: 'semver', 415 | predicate: '=', 416 | subject: 'ver', 417 | objects: ['1.0.0-rc1', '2.0.1', '2.1.0-beta2+build1201293821'] 418 | }); 419 | 420 | const user = new FPUser().with('ver', '1.0.0-rc1+build212'); 421 | expect(condition.meet(user)).toBeTruthy(); 422 | 423 | user.with('ver', '2.0.1+build3232231'); 424 | expect(condition.meet(user)).toBeTruthy(); 425 | 426 | user.with('ver', '2.0.1-rc3'); 427 | expect(condition.meet(user)).toBeFalsy(); 428 | }); 429 | 430 | test('[!=] semver condition match', () => { 431 | const condition = new Condition({ 432 | type: 'semver', 433 | predicate: '!=', 434 | subject: 'ver', 435 | objects: ['1.0.0-rc1', '2.0.1', '2.1.0-beta2+build1201293821'] 436 | }); 437 | 438 | const user = new FPUser().with('ver', '1.1.0'); 439 | expect(condition.meet(user)).toBeTruthy(); 440 | 441 | user.with('ver', '2.0.1'); 442 | expect(condition.meet(user)).toBeFalsy(); 443 | 444 | user.with('ver', '1.0.0-rc1+foo'); 445 | expect(condition.meet(user)).toBeFalsy(); 446 | 447 | user.with('ver', '2.1.0-beta2'); 448 | expect(condition.meet(user)).toBeFalsy(); 449 | }); 450 | 451 | test('[>] semver condition match', () => { 452 | const condition = new Condition({ 453 | type: 'semver', 454 | predicate: '>', 455 | subject: 'ver', 456 | objects: ['1.0.0-rc1', '2.0.1', '2.1.0-beta2+build1201293821'] 457 | }); 458 | 459 | const user = new FPUser().with('ver', '1.0.0-rc2'); 460 | expect(condition.meet(user)).toBeTruthy(); 461 | 462 | user.with('ver', '1.0.0-rc1'); 463 | expect(condition.meet(user)).toBeFalsy(); 464 | }); 465 | 466 | test('[>=] semver condition match', () => { 467 | const condition = new Condition({ 468 | type: 'semver', 469 | predicate: '>=', 470 | subject: 'ver', 471 | objects: ['1.0.0-rc1', '2.0.1', '2.1.0-beta2+build1201293821'] 472 | }); 473 | 474 | const user = new FPUser().with('ver', '1.1.0'); 475 | expect(condition.meet(user)).toBeTruthy(); 476 | 477 | user.with('ver', '2.0.1'); 478 | expect(condition.meet(user)).toBeTruthy(); 479 | 480 | user.with('ver', '0.1.0'); 481 | expect(condition.meet(user)).toBeFalsy(); 482 | }); 483 | 484 | test('[<] semver condition match', () => { 485 | const condition = new Condition({ 486 | type: 'semver', 487 | predicate: '<', 488 | subject: 'ver', 489 | objects: ['1.0.0-rc1', '2.0.1', '2.1.0-beta2+build1201293821'] 490 | }); 491 | 492 | const user = new FPUser().with('ver', '1.1.0'); 493 | expect(condition.meet(user)).toBeTruthy(); 494 | 495 | user.with('ver', '2.1.0-beta2'); 496 | expect(condition.meet(user)).toBeFalsy(); 497 | }); 498 | 499 | test('[<=] semver condition match', () => { 500 | const condition = new Condition({ 501 | type: 'semver', 502 | predicate: '<=', 503 | subject: 'ver', 504 | objects: ['1.0.0-rc1', '2.0.1', '2.1.0-beta2+build1201293821'] 505 | }); 506 | 507 | const user = new FPUser().with('ver', '1.0.0'); 508 | expect(condition.meet(user)).toBeTruthy(); 509 | 510 | user.with('ver', '2.0.1-alpha1'); 511 | expect(condition.meet(user)).toBeTruthy(); 512 | 513 | user.with('ver', '2.1.0-beta2'); 514 | expect(condition.meet(user)).toBeTruthy(); 515 | 516 | user.with('ver', '2.1.0-beta3'); 517 | expect(condition.meet(user)).toBeFalsy(); 518 | }); 519 | 520 | test('invalid semver condition', () => { 521 | 522 | const condition = new Condition({ 523 | type: 'semver', 524 | predicate: '>=', 525 | subject: 'ver', 526 | objects: ['foo'] 527 | }); 528 | 529 | // invalid predicate 530 | const user = new FPUser().with('ver', '1.0.0'); 531 | expect(condition.meet(user)).toBeFalsy(); 532 | 533 | user.with('ver', 'foo'); 534 | expect(condition.meet(user)).toBeFalsy(); 535 | 536 | condition.objects = ['1.2.1']; 537 | expect(condition.meet(user)).toBeFalsy(); 538 | }); 539 | 540 | test('get user split group', () => { 541 | const split = new Split(''); 542 | split.distribution = [[[0, 5000]], [[5000, 10000]]]; 543 | const user = new FPUser().stableRollout('test_user_key'); 544 | 545 | const commonIdx = split.findIndex(user, 'test_toggle_key'); 546 | split.bucketBy = 'email'; 547 | split.salt = 'abcddeafasde'; 548 | user.with('email', 'test@gmail.com'); 549 | const customIdx = split.findIndex(user, 'test_toggle_key'); 550 | 551 | expect(commonIdx.index).toBe(0); 552 | expect(customIdx.index).toBe(1); 553 | }); 554 | 555 | test('user has no key for split', () => { 556 | const split = new Split(''); 557 | split.distribution = [[[0, 5000]], [[5000, 10000]]]; 558 | const user = new FPUser(); 559 | 560 | const result1 = split.findIndex(user, 'test_toggle_key'); 561 | const key1 = user.key; 562 | const result2 = split.findIndex(user, 'test_toggle_key'); 563 | const key2 = user.key; 564 | 565 | expect(result1).toStrictEqual(result2); 566 | expect(key1).toBe(key2); 567 | }); 568 | -------------------------------------------------------------------------------- /src/Evaluate.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 FeatureProbe 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | 'use strict'; 18 | 19 | import { FPToggleDetail } from './type'; 20 | import { FPUser } from './FPUser'; 21 | 22 | import { createHash } from 'crypto'; 23 | 24 | const SemVer = require('semver/classes/semver'); 25 | 26 | const Defaults = { 27 | Split: { 28 | bucketSize: 10000, 29 | invalidIndex: -1 30 | } 31 | }; 32 | 33 | export class Repository { 34 | private _toggles: { [key: string]: Toggle }; 35 | private _segments: { [key: string]: Segment }; 36 | private _initialized = false; 37 | private _updatedTimestamp = 0; 38 | private _debugUntilTime = 0; 39 | 40 | constructor(json: any) { 41 | this._toggles = {}; 42 | this._segments = {}; 43 | for (const tk in json.toggles || {}) { 44 | this._toggles[tk] = new Toggle(json.toggles[tk]); 45 | } 46 | for (const sk in json.segments || {}) { 47 | this._segments[sk] = new Segment(json.segments[sk]); 48 | } 49 | this._debugUntilTime = json.debugUntilTime ?? 0; 50 | } 51 | 52 | get toggles(): { [key: string]: Toggle } { 53 | return Object.assign({}, this._toggles); 54 | } 55 | 56 | set toggles(value: { [key: string]: Toggle }) { 57 | this._toggles = value; 58 | } 59 | 60 | get segments(): { [key: string]: Segment } { 61 | return this._segments; 62 | } 63 | 64 | set segments(value: { [p: string]: Segment }) { 65 | this._segments = value; 66 | } 67 | 68 | get initialized(): boolean { 69 | return this._initialized; 70 | } 71 | 72 | set initialized(value: boolean) { 73 | this._initialized = value; 74 | } 75 | 76 | get updatedTimestamp(): number { 77 | return this._updatedTimestamp; 78 | } 79 | 80 | set updatedTimestamp(value: number) { 81 | this._updatedTimestamp = value; 82 | } 83 | 84 | get debugUntilTime(): number { 85 | return this._debugUntilTime; 86 | } 87 | 88 | set debugUntilTime(value: number) { 89 | this._debugUntilTime = value; 90 | } 91 | 92 | public getToggle(key: string): Toggle | undefined { 93 | return this._toggles[key]; 94 | } 95 | 96 | public clear() { 97 | this._toggles = {}; 98 | this._segments = {}; 99 | this._debugUntilTime = 0; 100 | } 101 | } 102 | 103 | export class Toggle { 104 | private _key: string; 105 | private _enabled: boolean; 106 | private _version: number; 107 | private _forClient: boolean; 108 | private _disabledServe: Serve; 109 | private _defaultServe: Serve; 110 | private _rules: Rule[]; 111 | private _variations: any[]; 112 | private _trackAccessEvents: boolean; 113 | private _prerequisites: Prerequisite[] 114 | 115 | constructor(json: any) { 116 | this._key = json.key; 117 | this._enabled = json.enabled || false; 118 | this._version = json.version || 1; 119 | this._forClient = json.forClient || false; 120 | this._trackAccessEvents = json.trackAccessEvents || false; 121 | this._disabledServe = new Serve(json.disabledServe); 122 | this._defaultServe = new Serve(json.defaultServe); 123 | this._rules = []; 124 | for (const r of json.rules || []) { 125 | this._rules.push(new Rule(r)); 126 | } 127 | this._variations = json.variations || []; 128 | this._prerequisites = json.prerequisites || []; 129 | } 130 | 131 | get key(): string { 132 | return this._key; 133 | } 134 | 135 | set key(value: string) { 136 | this._key = value; 137 | } 138 | 139 | get enabled(): boolean { 140 | return this._enabled; 141 | } 142 | 143 | set enabled(value: boolean) { 144 | this._enabled = value; 145 | } 146 | 147 | get version(): number { 148 | return this._version; 149 | } 150 | 151 | set version(value: number) { 152 | this._version = value; 153 | } 154 | 155 | get forClient(): boolean { 156 | return this._forClient; 157 | } 158 | 159 | set forClient(value: boolean) { 160 | this._forClient = value; 161 | } 162 | 163 | get disabledServe(): Serve { 164 | return this._disabledServe; 165 | } 166 | 167 | set disabledServe(value: Serve) { 168 | this._disabledServe = value; 169 | } 170 | 171 | get defaultServe(): Serve { 172 | return this._defaultServe; 173 | } 174 | 175 | set defaultServe(value: Serve) { 176 | this._defaultServe = value; 177 | } 178 | 179 | get rules(): Rule[] { 180 | return this._rules; 181 | } 182 | 183 | set rules(value: Rule[]) { 184 | this._rules = value; 185 | } 186 | 187 | get variations(): any[] { 188 | return this._variations; 189 | } 190 | 191 | set variations(value: any[]) { 192 | this._variations = value; 193 | } 194 | 195 | get trackAccessEvents(): boolean { 196 | return this._trackAccessEvents; 197 | } 198 | 199 | set trackAccessEvents(value: boolean) { 200 | this._trackAccessEvents = value; 201 | } 202 | 203 | public eval(user: FPUser, toggles: { [key: string]: Toggle }, segments: { [key: string]: Segment }, defaultValue: any, maxDeep: number = 20): FPToggleDetail { 204 | try { 205 | return this.doEval(user, toggles, segments, defaultValue, maxDeep); 206 | } catch (e) { 207 | return this.defaultResult(user, this._key, defaultValue, e + '.'); 208 | } 209 | } 210 | 211 | private doEval(user: FPUser, toggles: { [key: string]: Toggle }, segments: { [key: string]: Segment }, defaultValue: any, deep: number): FPToggleDetail { 212 | if (!this._enabled) { 213 | return this.disabledResult(user, this._key, defaultValue); 214 | } 215 | 216 | this.tryPrerequisite(user, toggles, segments, deep); 217 | 218 | let warning: string | null | undefined; 219 | if (this._rules?.length > 0) { 220 | for (let i = 0; i < this._rules.length; i++) { 221 | const rule = this._rules[i]; 222 | const hitResult = rule.hit(user, segments, this._key); 223 | if (hitResult.hit) { 224 | return this.hitValue(hitResult, defaultValue, i); 225 | } 226 | warning = hitResult.reason; 227 | } 228 | } 229 | return this.defaultResult(user, this._key, defaultValue, warning); 230 | } 231 | 232 | private tryPrerequisite(user: FPUser, toggles: { [key: string]: Toggle }, segments: { [key: string]: Segment }, deep: number) { 233 | if (deep === 0) { 234 | throw 'prerequisite deep overflow'; 235 | } 236 | 237 | for (const pre of this._prerequisites) { 238 | const toggle = toggles[pre.key]; 239 | if (toggle === undefined) { 240 | throw 'prerequisite not exist: ' + pre.key; 241 | } 242 | 243 | const detail = toggle.doEval(user, toggles, segments, null, deep - 1); 244 | if (detail.value !== pre.value) { 245 | throw 'prerequisite not match: ' + pre.key; 246 | } 247 | } 248 | } 249 | 250 | private hitValue(hitResult: IHitResult | undefined, defaultValue: any, ruleIndex?: number): FPToggleDetail { 251 | const res = { 252 | value: defaultValue, 253 | ruleIndex: ruleIndex ?? null, 254 | variationIndex: hitResult?.index ?? null, 255 | version: this._version, 256 | reason: hitResult?.reason || null, 257 | } as FPToggleDetail; 258 | 259 | if (hitResult?.index != null) { 260 | res.value = this._variations[hitResult.index]; 261 | if (ruleIndex !== undefined) { 262 | res.reason = `rule ${ruleIndex}`; 263 | } 264 | } 265 | return res; 266 | } 267 | 268 | private disabledResult(user: FPUser, toggleKey: string, defaultValue: any): FPToggleDetail { 269 | const disabledResult = this.hitValue(this._disabledServe?.evalIndex(user, toggleKey), defaultValue); 270 | disabledResult.reason = 'disabled.'; 271 | return disabledResult; 272 | } 273 | 274 | private defaultResult(user: FPUser, toggleKey: string, defaultValue: any, warning?: string | null): FPToggleDetail { 275 | const defaultResult = this.hitValue(this._defaultServe?.evalIndex(user, toggleKey), defaultValue); 276 | defaultResult.reason = `default. ${warning || ''}`.trimEnd(); 277 | return defaultResult; 278 | } 279 | } 280 | 281 | class Segment { 282 | private _key: string; 283 | private _uniqueId: string; 284 | private _version: number; 285 | private _rules: Rule[]; 286 | 287 | constructor(json: any) { 288 | this._key = json.key; 289 | this._uniqueId = json.uniqueId; 290 | this._version = json.version; 291 | this._rules = []; 292 | for (const r of json.rules || []) { 293 | this._rules.push(new Rule(r)); 294 | } 295 | } 296 | 297 | get key(): string { 298 | return this._key; 299 | } 300 | 301 | set key(value: string) { 302 | this._key = value; 303 | } 304 | 305 | get uniqueId(): string { 306 | return this._uniqueId; 307 | } 308 | 309 | set uniqueId(value: string) { 310 | this._uniqueId = value; 311 | } 312 | 313 | get version(): number { 314 | return this._version; 315 | } 316 | 317 | set version(value: number) { 318 | this._version = value; 319 | } 320 | 321 | get rules(): Rule[] { 322 | return this._rules; 323 | } 324 | 325 | set rules(value: Rule[]) { 326 | this._rules = value; 327 | } 328 | 329 | public contains(user: FPUser, segments: { [key: string]: Segment }) { 330 | return this._rules.some(rule => 331 | !rule.conditions.some(c => 332 | c.type !== 'segment' && user.getAttr(c.subject) === undefined 333 | || !c.meet(user, segments) 334 | ) 335 | ); 336 | } 337 | } 338 | 339 | class Serve { 340 | private _select: number; 341 | private _split: Split | null; 342 | 343 | constructor(json: any) { 344 | this._select = json.select; 345 | this._split = json.split ? new Split(json.split) : null; 346 | } 347 | 348 | get select(): number { 349 | return this._select; 350 | } 351 | 352 | set select(value: number) { 353 | this._select = value; 354 | } 355 | 356 | get split(): Split | null { 357 | return this._split; 358 | } 359 | 360 | set split(value: Split | null) { 361 | this._split = value; 362 | } 363 | 364 | public evalIndex(user: FPUser, toggleKey: string): IHitResult { 365 | if (this._select != null) { 366 | return { 367 | hit: true, 368 | index: this._select 369 | } as IHitResult; 370 | } 371 | return this._split?.findIndex(user, toggleKey) || { 372 | hit: false, 373 | reason: 'Serve.split is null' 374 | } as IHitResult; 375 | } 376 | } 377 | 378 | class Rule { 379 | private _serve: Serve | null; 380 | private _conditions: Condition[]; 381 | 382 | constructor(json: any) { 383 | this._serve = json.serve ? new Serve(json.serve) : null; 384 | this._conditions = []; 385 | for (const cond of json.conditions || []) { 386 | this._conditions.push(new Condition(cond)); 387 | } 388 | } 389 | 390 | get serve(): Serve | null { 391 | return this._serve; 392 | } 393 | 394 | set serve(value: Serve | null) { 395 | this._serve = value; 396 | } 397 | 398 | get conditions(): Condition[] { 399 | return this._conditions; 400 | } 401 | 402 | set conditions(value: Condition[]) { 403 | this._conditions = value; 404 | } 405 | 406 | public hit(user: FPUser, segments: { [key: string]: Segment }, toggleKey: string): IHitResult { 407 | for (const condition of this._conditions) { 408 | if (!['segment', 'datetime'].includes(condition.type) 409 | && user.getAttr(condition.subject) === undefined) { 410 | return { 411 | hit: false, 412 | reason: `warning: user with key '${user.key}' does not have attribute name '${condition.subject}'` 413 | } as IHitResult; 414 | } 415 | if (!condition.meet(user, segments)) { 416 | return { 417 | hit: false 418 | } as IHitResult; 419 | } 420 | } 421 | return this._serve?.evalIndex(user, toggleKey) || { 422 | hit: false, 423 | reason: 'Rule.serve is null' 424 | } as IHitResult; 425 | } 426 | } 427 | 428 | export class Split { 429 | private _distribution: number[][][]; 430 | private _bucketBy: string; 431 | private _salt: string; 432 | 433 | constructor(json: any) { 434 | this._distribution = json.distribution || []; 435 | this._bucketBy = json.bucketBy; 436 | this._salt = json.salt; 437 | } 438 | 439 | get distribution(): number[][][] { 440 | return this._distribution; 441 | } 442 | 443 | set distribution(value: number[][][]) { 444 | this._distribution = value; 445 | } 446 | 447 | get bucketBy(): string { 448 | return this._bucketBy; 449 | } 450 | 451 | set bucketBy(value: string) { 452 | this._bucketBy = value; 453 | } 454 | 455 | get salt(): string { 456 | return this._salt; 457 | } 458 | 459 | set salt(value: string) { 460 | this._salt = value; 461 | } 462 | 463 | public findIndex(user: FPUser, toggleKey: string): IHitResult { 464 | let hashKey = user.key; 465 | if (this._bucketBy?.trim().length > 0) { 466 | if (user.getAttr(this._bucketBy) !== undefined) { 467 | hashKey = user.getAttr(this._bucketBy) ?? user.key; 468 | } else { 469 | return { 470 | hit: false, 471 | reason: `Warning: User with key '${user.key}' does not have attribute name '${this._bucketBy}'` 472 | } as IHitResult; 473 | } 474 | } 475 | const groupIndex = this.getGroup(saltHash(hashKey, this._salt || toggleKey, Defaults.Split.bucketSize)); 476 | return { 477 | hit: true, 478 | index: groupIndex, 479 | reason: `selected ${groupIndex} percentage group` 480 | } as IHitResult; 481 | } 482 | 483 | private getGroup(hashValue: number): number { 484 | for (let i = 0; i < this._distribution.length; i++) { 485 | const groups = this._distribution[i]; 486 | for (const range of groups) { 487 | if (hashValue >= range[0] && hashValue < range[1]) { 488 | return i; 489 | } 490 | } 491 | } 492 | return Defaults.Split.invalidIndex; 493 | } 494 | } 495 | 496 | export class Condition { 497 | private _subject: string; 498 | private _objects: string[]; 499 | private _type: 'string' | 'segment' | 'datetime' | 'semver' | 'number'; 500 | private _predicate: string; 501 | 502 | constructor(json: any) { 503 | this._subject = json.subject; 504 | this._objects = json.objects || []; 505 | this._type = json.type; 506 | this._predicate = json.predicate; 507 | } 508 | 509 | get subject(): string { 510 | return this._subject; 511 | } 512 | 513 | set subject(value: string) { 514 | this._subject = value; 515 | } 516 | 517 | get objects(): string[] { 518 | return this._objects; 519 | } 520 | 521 | set objects(value: string[]) { 522 | this._objects = value; 523 | } 524 | 525 | get type(): 'string' | 'segment' | 'datetime' | 'semver' | 'number' { 526 | return this._type; 527 | } 528 | 529 | set type(value: 'string' | 'segment' | 'datetime' | 'semver' | 'number') { 530 | this._type = value; 531 | } 532 | 533 | get predicate(): string { 534 | return this._predicate; 535 | } 536 | 537 | set predicate(value: string) { 538 | this._predicate = value; 539 | } 540 | 541 | private static readonly StringPredicate: { 542 | [key: string]: (target: string, objects: string[]) => boolean 543 | } = { 544 | 'is one of': (target, objects) => objects.some(o => target === o), 545 | 'ends with': (target, objects) => objects.some(o => target.endsWith(o)), 546 | 'starts with': (target, objects) => objects.some(o => target.startsWith(o)), 547 | 'contains': (target, objects) => objects.some(o => target.includes(o)), 548 | 'matches regex': (target, objects) => objects.some(o => new RegExp(o).test(target)), 549 | 'is not any of': (target, objects) => !objects.some(o => target === o), 550 | 'does not end with': (target, objects) => !objects.some(o => target.endsWith(o)), 551 | 'does not start with': (target, objects) => !objects.some(o => target.startsWith(o)), 552 | 'does not contain': (target, objects) => !objects.some(o => target.includes(o)), 553 | 'does not match regex': (target, objects) => !objects.some(o => new RegExp(o).test(target)) 554 | }; 555 | 556 | private static readonly SegmentPredicate: { 557 | [key: string]: (user: FPUser, objects: string[], segments: { [key: string]: Segment }) => boolean 558 | } = { 559 | 'is in': (user, objects, segments) => objects.some(o => segments?.[o]?.contains(user, segments)), 560 | 'is not in': (user, objects, segments) => !objects.some(o => segments?.[o]?.contains(user, segments)) 561 | }; 562 | 563 | private static readonly DatetimePredicate: { 564 | [key: string]: (target: number, objects: string[]) => boolean 565 | } = { 566 | 'after': (target, objects) => objects.some(o => target >= parseInt(o)), 567 | 'before': (target, objects) => objects.some(o => target < parseInt(o)) 568 | }; 569 | 570 | private static readonly NumberPredicate: { 571 | [key: string]: (customValue: number, objects: string[]) => boolean 572 | } = { 573 | '=': (cv, objects) => objects.some(o => cv == parseFloat(o)), 574 | '!=': (cv, objects) => !objects.some(o => cv == parseFloat(o)), 575 | '>': (cv, objects) => objects.some(o => cv > parseFloat(o)), 576 | '>=': (cv, objects) => objects.some(o => cv >= parseFloat(o)), 577 | '<': (cv, objects) => objects.some(o => cv < parseFloat(o)), 578 | '<=': (cv, objects) => objects.some(o => cv <= parseFloat(o)) 579 | }; 580 | 581 | private static readonly SemverPredicate: { 582 | [key: string]: (customValue: typeof SemVer, objects: string[]) => boolean 583 | } = { 584 | '=': (cv, objects) => objects.some(o => cv.compare(o) === 0), 585 | '!=': (cv, objects) => !objects.some(o => cv.compare(o) === 0), 586 | '>': (cv, objects) => objects.some(o => cv.compare(o) > 0), 587 | '>=': (cv, objects) => objects.some(o => cv.compare(o) >= 0), 588 | '<': (cv, objects) => objects.some(o => cv.compare(o) < 0), 589 | '<=': (cv, objects) => objects.some(o => cv.compare(o) <= 0) 590 | }; 591 | 592 | public meet(user: FPUser, segments?: { [key: string]: Segment }): boolean { 593 | switch (this._type) { 594 | case 'string': 595 | return this.matchStringPredicate(user); 596 | case 'segment': 597 | return !!segments && this.matchSegmentPredicate(user, segments); 598 | case 'datetime': 599 | return this.matchDatetimePredicate(user); 600 | case 'semver': 601 | return this.matchSemverPredicate(user); 602 | case 'number': 603 | return this.matchNumberPredicate(user); 604 | default: 605 | return false; 606 | } 607 | } 608 | 609 | private matchStringPredicate(user: FPUser): boolean { 610 | const subjectVal = user.attrs[this._subject]; 611 | if (!subjectVal?.trim().length) { 612 | return false; 613 | } 614 | try { 615 | return !!Condition.StringPredicate[this._predicate]?.(subjectVal, this._objects); 616 | } catch { 617 | return false; 618 | } 619 | } 620 | 621 | private matchSegmentPredicate(user: FPUser, segments: { [key: string]: Segment }): boolean { 622 | return !!( Condition.SegmentPredicate )[this._predicate]?.(user, this._objects, segments); 623 | } 624 | 625 | private matchDatetimePredicate(user: FPUser): boolean { 626 | const res = user.attrs[this._subject]; 627 | let cv: number; 628 | try { 629 | cv = res === undefined ? Date.now() / 1000 : parseFloat(res); 630 | return !isNaN(cv) && !!Condition.DatetimePredicate[this._predicate]?.(cv, this._objects); 631 | } catch { 632 | return false; 633 | } 634 | } 635 | 636 | private matchSemverPredicate(user: FPUser): boolean { 637 | try { 638 | const cv = new SemVer(user.attrs[this._subject]); 639 | return !!Condition.SemverPredicate[this._predicate]?.(cv, this._objects); 640 | } catch { 641 | return false; 642 | } 643 | } 644 | 645 | private matchNumberPredicate(user: FPUser): boolean { 646 | try { 647 | const cv = parseFloat(user.attrs[this._subject]); 648 | return !isNaN(cv) && !!Condition.NumberPredicate[this._predicate]?.(cv, this._objects); 649 | } catch { 650 | return false; 651 | } 652 | } 653 | } 654 | 655 | class Prerequisite { 656 | private _key: string; 657 | private _value: any; 658 | 659 | constructor(json: any) { 660 | this._key = json.key; 661 | this._value = json.value; 662 | } 663 | 664 | public get key() : string { 665 | return this._key; 666 | } 667 | 668 | public get value(): any { 669 | return this._value; 670 | } 671 | 672 | } 673 | 674 | export interface IHitResult { 675 | hit: boolean; 676 | index?: number; 677 | reason?: string; 678 | } 679 | 680 | export function saltHash(key: string, salt: string, bucketSize: number): number { 681 | const sha = createHash('sha1').update(key + salt); 682 | const bytes = sha.digest('hex').slice(-8); 683 | return parseInt(bytes, 16) % bucketSize; 684 | } 685 | -------------------------------------------------------------------------------- /docs/interfaces/FPConfig.html: -------------------------------------------------------------------------------- 1 | FPConfig | FeatureProbe Server Side SDK for Node.js (2.2.0)
2 |
3 | 10 |
11 |
12 |
13 |
14 | 17 |

Interface FPConfig

18 |
19 |

Hierarchy

20 |
    21 |
  • FPConfig
24 |
25 |
26 |
27 | 28 |
29 |
30 |

Properties

31 |
40 |
41 |

Properties

42 |
43 | 44 |
eventsUrl?: string | URL
45 |

The specific URL to post events

46 |
49 |
50 | 51 |
logger?: Logger<LoggerOptions>
52 |

Pino logger.

53 |

If you want to use transport or advanced settings, 54 | please define one instance and pass to this param.

55 |
58 |
59 | 60 |
prerequisiteMaxDeep?: number
61 |

The max deep of prerequisite toggles

62 |
65 |
66 | 67 |
realtimeUrl?: string | URL
68 |

The specific URL to receive realtime event

69 |
72 |
73 | 74 |
refreshInterval?: number
75 |

The SDK check for updated in millisecond.

76 |
79 |
80 | 81 |
remoteUrl?: string | URL
82 |

The unified URL to get toggles and post events.

83 |
86 |
87 | 88 |
serverSdkKey: string
89 |

The server SDK Key for authentication.

90 |
93 |
94 | 95 |
togglesUrl?: string | URL
96 |

The specific URL to get toggles

97 |
100 |
133 |
134 |

Generated using TypeDoc

135 |
--------------------------------------------------------------------------------