├── .nvmrc ├── .github ├── CODEOWNERS └── workflows │ └── test.yml ├── src ├── index.ts ├── __tests__ │ ├── testUtils │ │ ├── fetchMock │ │ │ ├── package.json │ │ │ ├── browser.js │ │ │ └── node.js │ │ ├── browser.js │ │ ├── eventSourceMock.js │ │ └── index.js │ ├── nodeSuites │ │ ├── client_redis.spec.js │ │ ├── client.spec.js │ │ └── provider.spec.js │ └── mocks │ │ └── redis-commands.txt └── lib │ └── js-split-provider.ts ├── scripts ├── clean_umd_build.sh ├── build_cjs_replace_imports.sh ├── build_esm_replace_imports.sh ├── copy.packages.json.js └── sdk.unit.testing.setup ├── .pre-commit-config.yaml ├── split.yaml ├── tsconfig.json ├── LICENSE ├── jest.config.js ├── tsconfig.base.json ├── ts-node.register.js ├── eslint.config.mts ├── CHANGES.txt ├── project.json ├── CONTRIBUTORS-GUIDE.md ├── .gitignore ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @splitio/sdk 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './lib/js-split-provider'; 2 | -------------------------------------------------------------------------------- /src/__tests__/testUtils/fetchMock/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "./node.js", 3 | "browser": "./browser.js" 4 | } 5 | -------------------------------------------------------------------------------- /src/__tests__/testUtils/fetchMock/browser.js: -------------------------------------------------------------------------------- 1 | import fetchMock from 'fetch-mock'; 2 | 3 | // config the fetch mock to chain routes (appends the new route to the list of routes) 4 | fetchMock.config.overwriteRoutes = false; 5 | 6 | export default fetchMock; 7 | -------------------------------------------------------------------------------- /scripts/clean_umd_build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # remove unfetch source mapping url from UMD development build 4 | replace '//# sourceMappingURL=unfetch.module.js.map' '' ./umd -r 5 | 6 | if [ $? -eq 0 ] 7 | then 8 | exit 0 9 | else 10 | exit 1 11 | fi 12 | -------------------------------------------------------------------------------- /scripts/build_cjs_replace_imports.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # replace splitio-commons imports to use ES modules 4 | replace '@splitsoftware/splitio-commons/src' '@splitsoftware/splitio-commons/cjs' ./lib -r 5 | 6 | if [ $? -eq 0 ] 7 | then 8 | exit 0 9 | else 10 | exit 1 11 | fi 12 | -------------------------------------------------------------------------------- /src/__tests__/testUtils/browser.js: -------------------------------------------------------------------------------- 1 | // Util method to trigger 'unload' DOM event 2 | export function triggerUnloadEvent() { 3 | const event = document.createEvent('HTMLEvents'); 4 | event.initEvent('unload', true, true); 5 | event.eventName = 'unload'; 6 | window.dispatchEvent(event); 7 | } 8 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v6.0.0 4 | hooks: 5 | - id: check-added-large-files 6 | - id: check-json 7 | - id: check-yaml 8 | - id: end-of-file-fixer 9 | - id: trailing-whitespace 10 | -------------------------------------------------------------------------------- /scripts/build_esm_replace_imports.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # replace splitio-commons imports to use ES modules 4 | replace '@splitsoftware/splitio-commons/src' '@splitsoftware/splitio-commons/esm' ./es -r 5 | 6 | # Fix import extension in es/index.js 7 | replace './lib/js-split-provider' './lib/js-split-provider.js' ./es/index.js -r 8 | 9 | if [ $? -eq 0 ] 10 | then 11 | exit 0 12 | else 13 | exit 1 14 | fi 15 | -------------------------------------------------------------------------------- /src/__tests__/testUtils/fetchMock/node.js: -------------------------------------------------------------------------------- 1 | import fetchMock from 'fetch-mock'; 2 | import { __setFetch } from '../../../platform/getFetch/node'; 3 | 4 | const sandboxFetchMock = fetchMock.sandbox(); 5 | 6 | // config the fetch mock to chain routes (appends the new route to the list of routes) 7 | sandboxFetchMock.config.overwriteRoutes = false; 8 | 9 | __setFetch(sandboxFetchMock); 10 | 11 | export default sandboxFetchMock; 12 | -------------------------------------------------------------------------------- /split.yaml: -------------------------------------------------------------------------------- 1 | - my_feature: 2 | treatment: "on" 3 | keys: "key" 4 | config: "{\"desc\" : \"this applies only to ON treatment\"}" 5 | - my_feature: 6 | treatment: "off" 7 | - some_other_feature: 8 | treatment: "off" 9 | - int_feature: 10 | treatment: "32" 11 | config: "{\"desc\" : \"this applies only to number treatment\"}" 12 | - obj_feature: 13 | treatment: "{\"key\": \"value\"}" 14 | config: "{\"desc\" : \"this applies only to obj treatment\"}" -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "module": "es2020", 5 | "allowJs": true, 6 | "declaration": true, 7 | "declarationDir": "types", 8 | "importHelpers": true, 9 | "strict": true, 10 | "resolveJsonModule": true, 11 | "moduleResolution": "node", 12 | "allowSyntheticDefaultImports": true, 13 | "esModuleInterop": true, 14 | "skipLibCheck": true 15 | }, 16 | "include": [ 17 | "src" 18 | ], 19 | "exclude": [ 20 | "src/**/__tests__/**" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2025 Split Software, Inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /scripts/copy.packages.json.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const copyfiles = require('copyfiles'); 4 | 5 | const input = './src/**/package.json'; 6 | const outputCjsDir = './lib'; 7 | const outputEsmDir = './es'; 8 | 9 | copyfiles([input, process.env.NODE_ENV === 'cjs' ? outputCjsDir : outputEsmDir], { 10 | up: 1, 11 | exclude: './src/**/__tests__/**/package.json' 12 | }, (err) => { 13 | if (err) { 14 | console.log('Error copying package.json files: ' + err); 15 | process.exit(1); 16 | } else { 17 | console.log('All package.json files copied correctly.'); 18 | process.exit(0); 19 | } 20 | }); 21 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | displayName: 'js-split-provider', 3 | preset: 'ts-jest', 4 | 5 | // Test files are .js and .ts files inside of __tests__ folders and with a suffix of .test or .spec 6 | testMatch: ['/src/**/__tests__/**/?(*.)+(spec|test).[jt]s'], 7 | 8 | // Included files for test coverage (npm run test:coverage) 9 | collectCoverageFrom: [ 10 | 'src/**/*.{js,ts}', 11 | '!src/**/__tests__/**', 12 | '!src/**/*.d.ts' 13 | ], 14 | 15 | transform: { 16 | '^.+\\.[tj]s$': 'ts-jest', 17 | }, 18 | moduleFileExtensions: ['ts', 'js', 'html'], 19 | coverageDirectory: '../../coverage/packages/js-split-provider', 20 | }; 21 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "resolveJsonModule": true, 6 | "esModuleInterop": true, 7 | "sourceMap": true, 8 | "declaration": false, 9 | "moduleResolution": "node", 10 | "emitDecoratorMetadata": true, 11 | "experimentalDecorators": true, 12 | "strict": true, 13 | "importHelpers": true, 14 | "target": "es2015", 15 | "module": "esnext", 16 | "lib": ["es2017", "dom"], 17 | "skipLibCheck": true, 18 | "skipDefaultLibCheck": true, 19 | "baseUrl": ".", 20 | "outDir": "lib", 21 | "strictNullChecks": true, 22 | "types": ["node"] 23 | }, 24 | "exclude": ["node_modules", "tmp"] 25 | } 26 | -------------------------------------------------------------------------------- /ts-node.register.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Run tests and scripts in Node while transpiling typescript files from `@splitsoftware/splitio-commons/src` 3 | * https://www.npmjs.com/package/ts-node 4 | * 5 | * NOTE: can be used with `npm link @splitsoftware/splitio-commons` or `"@splitsoftware/splitio-commons": "file:../javascript-commons" without extra steps 6 | */ 7 | require('ts-node').register({ 8 | transpileOnly: true, // https://www.npmjs.com/package/ts-node#make-it-fast 9 | ignore: ['(?:^|/)node_modules/(?!@splitsoftware)'], // ignore transpiling node_modules except @splitsoftware (`ts-node` ignores node_modules by default) 10 | compilerOptions: { 11 | module: 'commonjs', // https://www.npmjs.com/package/ts-node#commonjs-vs-native-ecmascript-modules 12 | } 13 | }); 14 | -------------------------------------------------------------------------------- /eslint.config.mts: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import globals from "globals"; 3 | import tseslint from "typescript-eslint"; 4 | import { defineConfig } from "eslint/config"; 5 | import jestPlugin from 'eslint-plugin-jest'; 6 | 7 | 8 | export default defineConfig([ 9 | { 10 | files: ["**/*.{js,ts}"], 11 | plugins: { js }, 12 | extends: ["js/recommended"], 13 | languageOptions: { 14 | globals: globals.browser 15 | } 16 | }, 17 | { 18 | files: ['**/*.{test,spec}.{js,ts,jsx,tsx}', '**/__tests__/**/*.{js,ts,jsx,tsx}'], 19 | plugins: { jest: jestPlugin }, 20 | rules: { 21 | ...jestPlugin.configs.recommended.rules, 22 | }, 23 | languageOptions: { 24 | globals: { 25 | ...globals.jest, 26 | }, 27 | }, 28 | }, 29 | tseslint.configs.recommended, 30 | ]); 31 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | pull_request: 4 | branches: 5 | - '*' 6 | 7 | concurrency: 8 | group: ${{ github.workflow }}-${{ github.event.pull_request.number }} 9 | cancel-in-progress: true 10 | 11 | jobs: 12 | build: 13 | name: Run tests 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v5 18 | 19 | - name: Install Redis 20 | run: | 21 | sudo add-apt-repository ppa:redislabs/redis 22 | sudo apt-get install -y redis-tools redis-server 23 | 24 | - name: Check Redis 25 | run: redis-cli ping 26 | 27 | - name: Setup Node.js 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version: 'lts/*' 31 | cache: 'npm' 32 | 33 | - name: npm ci 34 | run: npm ci 35 | 36 | - name: npm check 37 | run: npm run check 38 | 39 | - name: npm test 40 | run: npm run test 41 | -------------------------------------------------------------------------------- /CHANGES.txt: -------------------------------------------------------------------------------- 1 | 1.2.0 (November 7, 2025) 2 | - Updated @openfeature/server-sdk to 1.20.0 3 | - Updated @splitsoftware/splitio to 11.8.0 4 | 5 | 1.1.0 (September 12, 2025) 6 | - Updated @openfeature/server-sdk to 1.19.0 7 | - Updated @splitsoftware/splitio to 11.4.1 8 | - Added support for tracking feature 9 | - Added support for evaluate with details feature 10 | - Added support for provider initialization using splitFactory and apiKey 11 | - Replace @openfeature/js-sdk with @openfeature/server-sdk 12 | 13 | 1.0.4 14 | - Fixes issue with TS build 15 | - Up to date with spec 0.5.0 and @openfeature/js-sdk 0.5.0 16 | 17 | 1.0.3 18 | - Adds types definitions for TypeScript 19 | - Up to date with spec 0.4.0 and @openfeature/js-sdk 0.4.0 20 | 21 | 1.0.2 22 | - Changes name from Node-specific implementation to generic JSON 23 | - Up to date with spec 0.4.0 and @openfeature/js-sdk 0.4.0 24 | 25 | 1.0.1 26 | - Fixes issues with flag details and error codes in negative cases, adds unit tests 27 | - Up to date with spec 0.4.0 and @openfeature/nodejs-sdk v0.3.2 28 | 29 | 1.0.0 30 | - First release. Up to date with spec 0.4.0, and @openfeature/nodejs-sdk v0.2.0 31 | -------------------------------------------------------------------------------- /project.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": "packages/js-split-provider", 3 | "sourceRoot": "packages/js-split-provider/src", 4 | "targets": { 5 | "build": { 6 | "executor": "@nrwl/node:webpack", 7 | "outputs": ["{options.outputPath}"], 8 | "options": { 9 | "outputPath": "dist/packages/js-split-provider", 10 | "main": "packages/js-split-provider/src/index.ts", 11 | "tsConfig": "packages/js-split-provider/tsconfig.lib.json" 12 | }, 13 | "configurations": { 14 | "production": { 15 | "optimization": true, 16 | "extractLicenses": true, 17 | "inspect": false 18 | } 19 | } 20 | }, 21 | "lint": { 22 | "executor": "@nrwl/linter:eslint", 23 | "outputs": ["{options.outputFile}"], 24 | "options": { 25 | "lintFilePatterns": ["packages/js-split-provider/**/*.ts"] 26 | } 27 | }, 28 | "test": { 29 | "executor": "@nrwl/jest:jest", 30 | "outputs": ["coverage/packages/js-split-provider"], 31 | "options": { 32 | "jestConfig": "packages/js-split-provider/jest.config.js", 33 | "passWithNoTests": true 34 | } 35 | } 36 | }, 37 | "tags": [] 38 | } 39 | -------------------------------------------------------------------------------- /CONTRIBUTORS-GUIDE.md: -------------------------------------------------------------------------------- 1 | # Contributing to the Split OpenFeature Provider 2 | 3 | The Split Provider is an open source project and we welcome feedback and contribution. The information below describes how to build the project with your changes, run the tests, and send the Pull Request(PR). 4 | 5 | ## Development 6 | 7 | ### Development process 8 | 9 | 1. Fork the repository and create a topic branch from `development` branch. Please use a descriptive name for your branch. 10 | 2. While developing, use descriptive messages in your commits. Avoid short or meaningless sentences like "fix bug". 11 | 3. Make sure to add tests for both positive and negative cases. 12 | 4. Run the build script and make sure it runs with no errors. 13 | 5. Run all tests and make sure there are no failures. 14 | 6. `git push` your changes to GitHub within your topic branch. 15 | 7. Open a Pull Request(PR) from your forked repo and into the `development` branch of the original repository. 16 | 8. When creating your PR, please fill out all the fields of the PR template, as applicable, for the project. 17 | 9. Check for conflicts once the pull request is created to make sure your PR can be merged cleanly into `development`. 18 | 10. Keep an eye out for any feedback or comments from the Split team. 19 | 20 | ### Building the Split Provider 21 | - `npm run build` 22 | 23 | ### Running tests 24 | - `npm run test` 25 | 26 | # Contact 27 | 28 | If you have any other questions or need to contact us directly in a private manner send us a note at sdks@split.io 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | dump.rdb 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # TypeScript v1 declaration files 46 | typings/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Microbundle cache 58 | .rpt2_cache/ 59 | .rts2_cache_cjs/ 60 | .rts2_cache_es/ 61 | .rts2_cache_umd/ 62 | 63 | # Optional REPL history 64 | .node_repl_history 65 | 66 | # Output of 'npm pack' 67 | *.tgz 68 | 69 | # Yarn Integrity file 70 | .yarn-integrity 71 | 72 | # dotenv environment variables file 73 | .env 74 | .env.test 75 | 76 | # parcel-bundler cache (https://parceljs.org/) 77 | .cache 78 | 79 | # Next.js build output 80 | .next 81 | 82 | # Nuxt.js build / generate output 83 | .nuxt 84 | dist 85 | 86 | # Gatsby files 87 | .cache/ 88 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 89 | # https://nextjs.org/blog/next-9-1#public-directory-support 90 | # public 91 | 92 | # vuepress build output 93 | .vuepress/dist 94 | 95 | # Serverless directories 96 | .serverless/ 97 | 98 | # FuseBox cache 99 | .fusebox/ 100 | 101 | # DynamoDB Local files 102 | .dynamodb/ 103 | 104 | # TernJS port file 105 | .tern-port 106 | 107 | # Build output 108 | es/ 109 | lib/ 110 | types/ 111 | -------------------------------------------------------------------------------- /src/__tests__/testUtils/eventSourceMock.js: -------------------------------------------------------------------------------- 1 | /** 2 | * EventEmitter mock based on https://github.com/gcedo/eventsourcemock/blob/master/src/EventSource.js 3 | * 4 | * To setup the mock assign it to the window object. 5 | * ``` 6 | * import EventSource from 'eventsourcemock'; 7 | * Object.defineProperty(window, 'EventSource', { 8 | * value: EventSource, 9 | * }); 10 | * ``` 11 | * 12 | */ 13 | 14 | import EventEmitter from 'events'; 15 | 16 | const defaultOptions = { 17 | withCredentials: false 18 | }; 19 | 20 | export const sources = {}; 21 | let __listener; 22 | export function setMockListener(listener) { 23 | __listener = listener; 24 | } 25 | 26 | export default class EventSource { 27 | 28 | constructor( 29 | url, 30 | eventSourceInitDict = defaultOptions 31 | ) { 32 | this.url = url; 33 | this.withCredentials = eventSourceInitDict.withCredentials; 34 | this.readyState = 0; 35 | this.__emitter = new EventEmitter(); 36 | this.__eventSourceInitDict = arguments[1]; 37 | sources[url] = this; 38 | if (__listener) setTimeout(__listener, 0, this); 39 | } 40 | 41 | addEventListener(eventName, listener) { 42 | this.__emitter.addListener(eventName, listener); 43 | } 44 | 45 | removeEventListener(eventName, listener) { 46 | this.__emitter.removeListener(eventName, listener); 47 | } 48 | 49 | close() { 50 | this.readyState = 2; 51 | } 52 | 53 | // The following methods can be used to mock EventSource behavior and events 54 | emit(eventName, messageEvent) { 55 | this.__emitter.emit(eventName, messageEvent); 56 | 57 | let listener; 58 | switch (eventName) { 59 | case 'error': listener = this.onerror; break; 60 | case 'open': listener = this.onopen; break; 61 | case 'message': listener = this.onmessage; break; 62 | } 63 | if (typeof listener === 'function') { 64 | listener(messageEvent); 65 | } 66 | } 67 | 68 | emitError(error) { 69 | this.emit('error', error); 70 | } 71 | 72 | emitOpen() { 73 | this.readyState = 1; 74 | this.emit('open'); 75 | } 76 | 77 | emitMessage(message) { 78 | this.emit('message', message); 79 | } 80 | } 81 | 82 | EventSource.CONNECTING = 0; 83 | EventSource.OPEN = 1; 84 | EventSource.CLOSED = 2; 85 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@splitsoftware/openfeature-js-split-provider", 3 | "version": "1.2.0", 4 | "description": "Split OpenFeature Provider", 5 | "files": [ 6 | "README.md", 7 | "CONTRIBUTORS-GUIDE.md", 8 | "LICENSE", 9 | "CHANGES.txt", 10 | "lib", 11 | "types", 12 | "es", 13 | "src" 14 | ], 15 | "repository": "splitio/openfeature-split-provider-js", 16 | "homepage": "https://github.com/splitio/openfeature-split-provider-js#readme", 17 | "bugs": "https://github.com/splitio/openfeature-split-provider-js/issues", 18 | "license": "Apache-2.0", 19 | "author": "Josh Sirota ", 20 | "contributors": [ 21 | "Nicolas Zelaya (https://github.com/NicoZelaya)", 22 | "Emiliano Sanchez (https://github.com/EmilianoSanchez)", 23 | "Emmanuel Zamora (https://github.com/ZamoraEmmanuel)", 24 | "SDK Team " 25 | ], 26 | "main": "lib/index.js", 27 | "types": "types/index.d.ts", 28 | "engines": { 29 | "node": ">=14" 30 | }, 31 | "exports": { 32 | ".": { 33 | "require": "./lib/index.js", 34 | "import": "./es/index.js" 35 | } 36 | }, 37 | "peerDependencies": { 38 | "@openfeature/server-sdk": "^1.20.0", 39 | "@splitsoftware/splitio": "^11.8.0" 40 | }, 41 | "devDependencies": { 42 | "@eslint/js": "^9.35.0", 43 | "@openfeature/server-sdk": "^1.20.0", 44 | "@splitsoftware/splitio": "^11.8.0", 45 | "@types/jest": "^30.0.0", 46 | "@types/node": "^24.3.1", 47 | "copyfiles": "^2.4.1", 48 | "cross-env": "^7.0.3", 49 | "eslint": "^9.35.0", 50 | "eslint-plugin-jest": "^28.14.0", 51 | "globals": "^16.3.0", 52 | "jest": "^29.7.0", 53 | "jiti": "^2.5.1", 54 | "redis-server": "^1.2.2", 55 | "replace": "^1.2.1", 56 | "rimraf": "^3.0.2", 57 | "ts-jest": "^29.4.1", 58 | "ts-node": "^10.5.0", 59 | "typescript": "^4.9.5", 60 | "typescript-eslint": "^8.43.0" 61 | }, 62 | "scripts": { 63 | "build-esm": "rimraf es && tsc -outDir es", 64 | "postbuild-esm": "cross-env NODE_ENV=es node scripts/copy.packages.json.js && ./scripts/build_esm_replace_imports.sh", 65 | "build-cjs": "rimraf lib && tsc -outDir lib -m CommonJS", 66 | "postbuild-cjs": "cross-env NODE_ENV=cjs node scripts/copy.packages.json.js && ./scripts/build_cjs_replace_imports.sh", 67 | "build": "rimraf lib es && npm run build-cjs && npm run build-esm", 68 | "check": "npm run check:lint", 69 | "check:lint": "eslint src", 70 | "test": "cross-env NODE_ENV=test jest", 71 | "publish:rc": "npm run check && npm run test && npm run build && npm publish --tag rc", 72 | "publish:stable": "npm run check && npm run test && npm run build && npm publish" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/__tests__/testUtils/index.js: -------------------------------------------------------------------------------- 1 | import { SplitFactory } from '@splitsoftware/splitio'; 2 | 3 | const DEFAULT_ERROR_MARGIN = 50; // 0.05 secs 4 | 5 | /** 6 | * Assert if an `actual` and `expected` numeric values are nearlyEqual. 7 | * 8 | * @param {number} actual actual time lapse in millis 9 | * @param {number} expected expected time lapse in millis 10 | * @param {number} epsilon error margin in millis 11 | * @returns {boolean} whether the absolute difference is minor to epsilon value or not 12 | */ 13 | export function nearlyEqual(actual, expected, epsilon = DEFAULT_ERROR_MARGIN) { 14 | const diff = Math.abs(actual - expected); 15 | return diff <= epsilon; 16 | } 17 | 18 | /** 19 | * mock the basic behaviour for `/segmentChanges` endpoint: 20 | * - when `?since=-1`, it returns the given segment `keys` in `added` list. 21 | * - otherwise, it returns empty `added` and `removed` lists, and the same since and till values. 22 | * 23 | * @param {Object} fetchMock see http://www.wheresrhys.co.uk/fetch-mock 24 | * @param {string|RegExp|...} matcher see http://www.wheresrhys.co.uk/fetch-mock/#api-mockingmock_matcher 25 | * @param {string[]} keys array of segment keys to fetch 26 | * @param {number} changeNumber optional changeNumber 27 | */ 28 | export function mockSegmentChanges(fetchMock, matcher, keys, changeNumber = 1457552620999) { 29 | fetchMock.get(matcher, function (url) { 30 | const since = parseInt(url.split('=').pop()); 31 | const name = url.split('?')[0].split('/').pop(); 32 | return { 33 | status: 200, body: { 34 | 'name': name, 35 | 'added': since === -1 ? keys : [], 36 | 'removed': [], 37 | 'since': since, 38 | 'till': since === -1 ? changeNumber : since, 39 | } 40 | }; 41 | }); 42 | } 43 | 44 | export function hasNoCacheHeader(fetchMockOpts) { 45 | return fetchMockOpts.headers['Cache-Control'] === 'no-cache'; 46 | } 47 | 48 | const eventsEndpointMatcher = /^\/(testImpressions|metrics|events)/; 49 | const authEndpointMatcher = /^\/v2\/auth/; 50 | const streamingEndpointMatcher = /^\/(sse|event-stream)/; 51 | 52 | /** 53 | * Switch URLs servers based on target. 54 | * Only used for testing purposes. 55 | * 56 | * @param {Object} settings settings object 57 | * @param {String} target url path 58 | * @return {String} completed url 59 | */ 60 | export function url(settings, target) { 61 | if (eventsEndpointMatcher.test(target)) { 62 | return `${settings.urls.events}${target}`; 63 | } 64 | if (authEndpointMatcher.test(target)) { 65 | return `${settings.urls.auth}${target}`; 66 | } 67 | if (streamingEndpointMatcher.test(target)) { 68 | return `${settings.urls.streaming}${target}`; 69 | } 70 | return `${settings.urls.sdk}${target}`; 71 | } 72 | 73 | const getRedisConfig = (redisPort) => ({ 74 | core: { 75 | authorizationKey: 'SOME SDK KEY' // in consumer mode, SDK key is only used to track and log warning regarding duplicated SDK instances 76 | }, 77 | mode: 'consumer', 78 | storage: { 79 | type: 'REDIS', 80 | prefix: 'REDIS_NODE_UT', 81 | options: { 82 | url: `redis://localhost:${redisPort}/0` 83 | } 84 | }, 85 | sync: { 86 | impressionsMode: 'DEBUG' 87 | }, 88 | startup: { 89 | readyTimeout: 36000 // 10hs 90 | } 91 | }); 92 | 93 | const config = { 94 | core: { 95 | authorizationKey: 'localhost' 96 | }, 97 | features: './split.yaml', 98 | } 99 | /** 100 | * get a Split client in localhost mode for testing purposes 101 | */ 102 | export function getLocalHostSplitClient() { 103 | return SplitFactory(config).client(); 104 | } 105 | 106 | export function getRedisSplitClient(redisPort) { 107 | return SplitFactory(getRedisConfig(redisPort)).client(); 108 | } 109 | 110 | export function getSplitFactory() { 111 | return SplitFactory(config); 112 | } 113 | -------------------------------------------------------------------------------- /src/__tests__/nodeSuites/client_redis.spec.js: -------------------------------------------------------------------------------- 1 | import RedisServer from 'redis-server'; 2 | import { exec } from 'child_process'; 3 | import { OpenFeature } from '@openfeature/server-sdk'; 4 | 5 | import { getRedisSplitClient } from '../testUtils'; 6 | import { OpenFeatureSplitProvider } from '../../lib/js-split-provider'; 7 | 8 | const redisPort = '6385'; 9 | 10 | /** 11 | * Initialize redis server and run a cli bash command to load redis with data to do the proper tests 12 | */ 13 | const startRedis = () => { 14 | // Simply pass the port that you want a Redis server to listen on. 15 | const server = new RedisServer(redisPort); 16 | 17 | const promise = new Promise((resolve, reject) => { 18 | server 19 | .open() 20 | .then(() => { 21 | exec(`cat ./src/__tests__/mocks/redis-commands.txt | redis-cli -p ${redisPort}`, err => { 22 | if (err) { 23 | reject(server); 24 | // Node.js couldn't execute the command 25 | return; 26 | } 27 | resolve(server); 28 | }); 29 | }); 30 | }); 31 | 32 | return promise; 33 | }; 34 | 35 | let redisServer 36 | let splitClient 37 | 38 | beforeAll(async () => { 39 | redisServer = await startRedis(); 40 | }, 30000); 41 | 42 | afterAll(async () => { 43 | await redisServer.close(); 44 | await splitClient.destroy(); 45 | }); 46 | 47 | describe('Regular usage - DEBUG strategy', () => { 48 | splitClient = getRedisSplitClient(redisPort); 49 | const provider = new OpenFeatureSplitProvider({ splitClient }); 50 | 51 | OpenFeature.setProviderAndWait(provider); 52 | const client = OpenFeature.getClient(); 53 | 54 | test('Evaluate always on flag', async () => { 55 | await client.getBooleanValue('always-on', false, {targetingKey: 'emma-ss'}).then(result => { 56 | expect(result).toBe(true); 57 | }); 58 | }); 59 | 60 | test('Evaluate user in segment', async () => { 61 | await client.getBooleanValue('UT_IN_SEGMENT', false, {targetingKey: 'UT_Segment_member', properties: { /* empty properties are ignored */ }}).then(result => { 62 | expect(result).toBe(true); 63 | }); 64 | 65 | await client.getBooleanValue('UT_IN_SEGMENT', false, {targetingKey: 'UT_Segment_member', properties: { some: 'value1' } }).then(result => { 66 | expect(result).toBe(true); 67 | }); 68 | 69 | await client.getBooleanValue('UT_IN_SEGMENT', false, {targetingKey: 'other' }).then(result => { 70 | expect(result).toBe(false); 71 | }); 72 | 73 | await client.getBooleanValue('UT_NOT_IN_SEGMENT', true, {targetingKey: 'UT_Segment_member' }).then(result => { 74 | expect(result).toBe(false); 75 | }); 76 | 77 | await client.getBooleanValue('UT_NOT_IN_SEGMENT', true, {targetingKey: 'other' }).then(result => { 78 | expect(result).toBe(true); 79 | }); 80 | 81 | await client.getBooleanValue('UT_NOT_IN_SEGMENT', true, {targetingKey: 'other' }).then(result => { 82 | expect(result).toBe(true); 83 | }); 84 | }); 85 | 86 | test('Evaluate with attributes set matcher', async () => { 87 | await client.getBooleanValue('UT_SET_MATCHER', false, {targetingKey: 'UT_Segment_member', permissions: ['admin'] }).then(result => { 88 | expect(result).toBe(true); 89 | }); 90 | 91 | await client.getBooleanValue('UT_SET_MATCHER', false, {targetingKey: 'UT_Segment_member', permissions: ['not_matching'] }).then(result => { 92 | expect(result).toBe(false); 93 | }); 94 | 95 | await client.getBooleanValue('UT_NOT_SET_MATCHER', true, {targetingKey: 'UT_Segment_member', permissions: ['create'] }).then(result => { 96 | expect(result).toBe(false); 97 | }); 98 | 99 | await client.getBooleanValue('UT_NOT_SET_MATCHER', true, {targetingKey: 'UT_Segment_member', permissions: ['not_matching'] }).then(result => { 100 | expect(result).toBe(true); 101 | }); 102 | }) 103 | 104 | test('Evaluate with dynamic config', async () => { 105 | await client.getBooleanDetails('UT_NOT_SET_MATCHER', true, {targetingKey: 'UT_Segment_member', permissions: ['not_matching'] }).then(result => { 106 | expect(result.value).toBe(true); 107 | expect(result.flagMetadata).toEqual({'config': ''}); 108 | }); 109 | 110 | await client.getStringDetails('always-o.n-with-config', 'control', {targetingKey: 'other'}).then(result => { 111 | expect(result.value).toBe('o.n'); 112 | expect(result.flagMetadata).toEqual({config: '{"color":"brown"}'}); 113 | }); 114 | }) 115 | }); 116 | -------------------------------------------------------------------------------- /src/__tests__/mocks/redis-commands.txt: -------------------------------------------------------------------------------- 1 | FLUSHDB 2 | DEL 'REDIS_NODE_UT.SPLITIO.segment.UT_SEGMENT' 3 | SADD 'REDIS_NODE_UT.SPLITIO.segment.UT_SEGMENT' UT_Segment_member 4 | SET 'REDIS_NODE_UT.SPLITIO.segment.UT_SEGMENT.till' 1492721958710 5 | SET 'REDIS_NODE_UT.SPLITIO.split.UT_IN_SEGMENT' '{"changeNumber":1492722104980,"trafficTypeName":"machine","name":"UT_IN_SEGMENT","seed":-202209840,"status":"ACTIVE","killed":false,"defaultTreatment":"off","conditions":[{"matcherGroup":{"combiner":"AND","matchers":[{"keySelector":{"trafficType":"","attribute":""},"matcherType":"IN_SEGMENT","negate":false,"userDefinedSegmentMatcherData":{"segmentName":"UT_SEGMENT"},"unaryNumericMatcherData":{"dataType":"","value":0},"whitelistMatcherData":{"whitelist":null},"betweenMatcherData":{"dataType":"","start":0,"end":0}}]},"partitions":[{"treatment":"on","size":100}],"label":"whitelisted segment"},{"matcherGroup":{"combiner":"AND","matchers":[{"keySelector":{"trafficType":"machine","attribute":""},"matcherType":"ALL_KEYS","negate":false,"userDefinedSegmentMatcherData":{"segmentName":""},"unaryNumericMatcherData":{"dataType":"","value":0},"whitelistMatcherData":{"whitelist":null},"betweenMatcherData":{"dataType":"","start":0,"end":0}}]},"partitions":[{"treatment":"on","size":0},{"treatment":"off","size":100}],"label":"in segment all"}]}' 6 | SET 'REDIS_NODE_UT.SPLITIO.split.UT_NOT_IN_SEGMENT' '{"changeNumber":1492722747908,"trafficTypeName":"machine","name":"UT_NOT_IN_SEGMENT","seed":-56653132,"status":"ACTIVE","killed":false,"defaultTreatment":"off","conditions":[{"matcherGroup":{"combiner":"AND","matchers":[{"keySelector":{"trafficType":"machine","attribute":""},"matcherType":"IN_SEGMENT","negate":true,"userDefinedSegmentMatcherData":{"segmentName":"UT_SEGMENT"},"unaryNumericMatcherData":{"dataType":"","value":0},"whitelistMatcherData":{"whitelist":null},"betweenMatcherData":{"dataType":"","start":0,"end":0}}]},"partitions":[{"treatment":"on","size":100},{"treatment":"off","size":0}],"label":"not in segment UT_SEGMENT"}]}' 7 | SET 'REDIS_NODE_UT.SPLITIO.split.UT_NOT_SET_MATCHER' '{"changeNumber":1492723024413,"trafficTypeName":"machine","name":"UT_NOT_SET_MATCHER","seed":-93553840,"status":"ACTIVE","killed":false,"defaultTreatment":"off","conditions":[{"matcherGroup":{"combiner":"AND","matchers":[{"keySelector":{"trafficType":"machine","attribute":"permissions"},"matcherType":"CONTAINS_ANY_OF_SET","negate":true,"userDefinedSegmentMatcherData":{"segmentName":""},"unaryNumericMatcherData":{"dataType":"","value":0},"whitelistMatcherData":{"whitelist":["create","delete","update"]},"betweenMatcherData":{"dataType":"","start":0,"end":0}}]},"partitions":[{"treatment":"on","size":100},{"treatment":"off","size":0}],"label":"permissions does not contain any of [create, delete, ...]"}]}' 8 | SET 'REDIS_NODE_UT.SPLITIO.split.UT_SET_MATCHER' '{"changeNumber":1492722926004,"trafficTypeName":"machine","name":"UT_SET_MATCHER","seed":-1995997836,"status":"ACTIVE","killed":false,"defaultTreatment":"off","conditions":[{"matcherGroup":{"combiner":"AND","matchers":[{"keySelector":{"trafficType":"machine","attribute":"permissions"},"matcherType":"CONTAINS_ANY_OF_SET","negate":false,"userDefinedSegmentMatcherData":{"segmentName":""},"unaryNumericMatcherData":{"dataType":"","value":0},"whitelistMatcherData":{"whitelist":["admin","premium","idol"]},"betweenMatcherData":{"dataType":"","start":0,"end":0}}]},"partitions":[{"treatment":"on","size":100},{"treatment":"off","size":0}],"label":"permissions contains any of [admin, premium, ...]"}]}' 9 | SET 'REDIS_NODE_UT.SPLITIO.split.always-on' '{"changeNumber":1487277320548,"trafficTypeName":"user","name":"always-on","seed":1684183541,"status":"ACTIVE","killed":false,"defaultTreatment":"off","conditions":[{"matcherGroup":{"combiner":"AND","matchers":[{"keySelector":{"trafficType":"user","attribute":""},"matcherType":"ALL_KEYS","negate":false,"userDefinedSegmentMatcherData":{"segmentName":""},"unaryNumericMatcherData":{"dataType":"","value":0},"whitelistMatcherData":{"whitelist":null},"betweenMatcherData":{"dataType":"","start":0,"end":0}}]},"partitions":[{"treatment":"on","size":100},{"treatment":"off","size":0}],"label":"in segment all"}]}' 10 | SET 'REDIS_NODE_UT.SPLITIO.split.always-o.n-with-config' '{"changeNumber":1487277320548,"trafficTypeName":"user","name":"always-o.n-with-config","seed":1684183541,"status":"ACTIVE","killed":false,"defaultTreatment":"off","conditions":[{"matcherGroup":{"combiner":"AND","matchers":[{"keySelector":{"trafficType":"user","attribute":""},"matcherType":"ALL_KEYS","negate":false,"userDefinedSegmentMatcherData":{"segmentName":""},"unaryNumericMatcherData":{"dataType":"","value":0},"whitelistMatcherData":{"whitelist":null},"betweenMatcherData":{"dataType":"","start":0,"end":0}}]},"partitions":[{"treatment":"o.n","size":100},{"treatment":"off","size":0}],"label":"in segment all"}],"configurations":{"o.n":"{\"color\":\"brown\"}"}}' 11 | SET 'REDIS_NODE_UT.SPLITIO.splits.till' 1492723024413 12 | -------------------------------------------------------------------------------- /src/lib/js-split-provider.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EvaluationContext, 3 | FlagNotFoundError, 4 | JsonValue, 5 | OpenFeatureEventEmitter, 6 | ParseError, 7 | Provider, 8 | ProviderEvents, 9 | ResolutionDetails, 10 | StandardResolutionReasons, 11 | TargetingKeyMissingError, 12 | TrackingEventDetails 13 | } from '@openfeature/server-sdk'; 14 | import { SplitFactory } from '@splitsoftware/splitio'; 15 | import type SplitIO from '@splitsoftware/splitio/types/splitio'; 16 | 17 | type SplitProviderOptions = { 18 | splitClient: SplitIO.IClient | SplitIO.IAsyncClient; 19 | } 20 | 21 | type Consumer = { 22 | targetingKey: string | undefined; 23 | trafficType: string; 24 | attributes: SplitIO.Attributes; 25 | }; 26 | 27 | const CONTROL_VALUE_ERROR_MESSAGE = 'Received the "control" value from Split.'; 28 | const CONTROL_TREATMENT = 'control'; 29 | 30 | export class OpenFeatureSplitProvider implements Provider { 31 | metadata = { 32 | name: 'split', 33 | }; 34 | 35 | private client: SplitIO.IClient | SplitIO.IAsyncClient; 36 | private trafficType: string; 37 | public readonly events = new OpenFeatureEventEmitter(); 38 | 39 | private getSplitClient(options: SplitProviderOptions | string | SplitIO.ISDK | SplitIO.IAsyncSDK) { 40 | if (typeof(options) === 'string') { 41 | const splitFactory = SplitFactory({core: { authorizationKey: options } }); 42 | return splitFactory.client(); 43 | } 44 | 45 | let splitClient; 46 | try { 47 | splitClient = (options as SplitIO.ISDK | SplitIO.IAsyncSDK).client(); 48 | } catch { 49 | splitClient = (options as SplitProviderOptions).splitClient 50 | } 51 | 52 | return splitClient; 53 | } 54 | 55 | constructor(options: SplitProviderOptions | string | SplitIO.ISDK | SplitIO.IAsyncSDK) { 56 | // Asume 'user' as default traffic type' 57 | this.trafficType = 'user'; 58 | this.client = this.getSplitClient(options); 59 | this.client.on(this.client.Event.SDK_UPDATE, () => { 60 | this.events.emit(ProviderEvents.ConfigurationChanged); 61 | }); 62 | } 63 | 64 | public async resolveBooleanEvaluation( 65 | flagKey: string, 66 | _: boolean, 67 | context: EvaluationContext 68 | ): Promise> { 69 | const details = await this.evaluateTreatment( 70 | flagKey, 71 | this.transformContext(context) 72 | ); 73 | const treatment = details.value.toLowerCase(); 74 | 75 | if ( treatment === 'on' || treatment === 'true' ) { 76 | return { ...details, value: true }; 77 | } 78 | 79 | if ( treatment === 'off' || treatment === 'false' ) { 80 | return { ...details, value: false }; 81 | } 82 | 83 | throw new ParseError(`Invalid boolean value for ${treatment}`); 84 | } 85 | 86 | public async resolveStringEvaluation( 87 | flagKey: string, 88 | _: string, 89 | context: EvaluationContext 90 | ): Promise> { 91 | const details = await this.evaluateTreatment( 92 | flagKey, 93 | this.transformContext(context) 94 | ); 95 | return details; 96 | } 97 | 98 | public async resolveNumberEvaluation( 99 | flagKey: string, 100 | _: number, 101 | context: EvaluationContext 102 | ): Promise> { 103 | const details = await this.evaluateTreatment( 104 | flagKey, 105 | this.transformContext(context) 106 | ); 107 | return { ...details, value: this.parseValidNumber(details.value) }; 108 | } 109 | 110 | public async resolveObjectEvaluation( 111 | flagKey: string, 112 | _: U, 113 | context: EvaluationContext 114 | ): Promise> { 115 | const details = await this.evaluateTreatment( 116 | flagKey, 117 | this.transformContext(context) 118 | ); 119 | return { ...details, value: this.parseValidJsonObject(details.value) }; 120 | } 121 | 122 | private async evaluateTreatment( 123 | flagKey: string, 124 | consumer: Consumer 125 | ): Promise> { 126 | if (!consumer.targetingKey) { 127 | throw new TargetingKeyMissingError( 128 | 'The Split provider requires a targeting key.' 129 | ); 130 | } 131 | if (flagKey == null || flagKey === '') { 132 | throw new FlagNotFoundError( 133 | 'flagKey must be a non-empty string' 134 | ); 135 | } 136 | 137 | await new Promise((resolve, reject) => { 138 | this.readinessHandler(resolve, reject); 139 | }); 140 | 141 | const { treatment: value, config }: SplitIO.TreatmentWithConfig = await this.client.getTreatmentWithConfig( 142 | consumer.targetingKey, 143 | flagKey, 144 | consumer.attributes 145 | ); 146 | if (value === CONTROL_TREATMENT) { 147 | throw new FlagNotFoundError(CONTROL_VALUE_ERROR_MESSAGE); 148 | } 149 | const flagMetadata = { config: config ? config : '' }; 150 | const details: ResolutionDetails = { 151 | value: value, 152 | variant: value, 153 | flagMetadata: flagMetadata, 154 | reason: StandardResolutionReasons.TARGETING_MATCH, 155 | }; 156 | return details; 157 | } 158 | 159 | async track( 160 | trackingEventName: string, 161 | context: EvaluationContext, 162 | details: TrackingEventDetails 163 | ): Promise { 164 | 165 | // eventName is always required 166 | if (trackingEventName == null || trackingEventName === '') 167 | throw new ParseError('Missing eventName, required to track'); 168 | 169 | // targetingKey is always required 170 | const { targetingKey, trafficType } = this.transformContext(context); 171 | if (targetingKey == null || targetingKey === '') 172 | throw new TargetingKeyMissingError('Missing targetingKey, required to track'); 173 | 174 | let value; 175 | let properties: SplitIO.Properties = {}; 176 | if (details != null) { 177 | if (details.value != null) { 178 | value = details.value; 179 | } 180 | if (details.properties != null) { 181 | properties = details.properties as SplitIO.Properties; 182 | } 183 | } 184 | 185 | this.client.track(targetingKey, trafficType, trackingEventName, value, properties); 186 | } 187 | 188 | public async onClose?(): Promise { 189 | return this.client.destroy(); 190 | } 191 | 192 | //Transform the context into an object useful for the Split API, an key string with arbitrary Split 'Attributes'. 193 | private transformContext(context: EvaluationContext): Consumer { 194 | const { targetingKey, trafficType: ttVal, ...attributes } = context; 195 | const trafficType = 196 | ttVal != null && typeof ttVal === 'string' && ttVal.trim() !== '' 197 | ? ttVal 198 | : this.trafficType; 199 | return { 200 | targetingKey, 201 | trafficType, 202 | // Stringify context objects include date. 203 | attributes: JSON.parse(JSON.stringify(attributes)), 204 | }; 205 | } 206 | 207 | private parseValidNumber(stringValue: string | undefined) { 208 | if (stringValue === undefined) { 209 | throw new ParseError(`Invalid 'undefined' value.`); 210 | } 211 | const result = Number.parseFloat(stringValue); 212 | if (Number.isNaN(result)) { 213 | throw new ParseError(`Invalid numeric value ${stringValue}`); 214 | } 215 | return result; 216 | } 217 | 218 | private parseValidJsonObject( 219 | stringValue: string | undefined 220 | ): T { 221 | if (stringValue === undefined) { 222 | throw new ParseError(`Invalid 'undefined' JSON value.`); 223 | } 224 | // we may want to allow the parsing to be customized. 225 | try { 226 | const value = JSON.parse(stringValue); 227 | if (typeof value !== 'object') { 228 | throw new ParseError( 229 | `Flag value ${stringValue} had unexpected type ${typeof value}, expected "object"` 230 | ); 231 | } 232 | return value; 233 | } catch (err) { 234 | throw new ParseError(`Error parsing ${stringValue} as JSON, ${err}`); 235 | } 236 | } 237 | 238 | private async readinessHandler(onSdkReady: (params?: unknown) => void, onSdkTimedOut: () => void): Promise { 239 | 240 | const clientStatus = this.client.getStatus(); 241 | if (clientStatus.isReady) { 242 | onSdkReady(); 243 | } else { 244 | if (clientStatus.hasTimedout) { 245 | onSdkTimedOut(); 246 | } else { 247 | this.client.on(this.client.Event.SDK_READY_TIMED_OUT, onSdkTimedOut); 248 | } 249 | this.client.on(this.client.Event.SDK_READY, onSdkReady); 250 | } 251 | } 252 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Split OpenFeature Provider for NodeJS 2 | [![Twitter Follow](https://img.shields.io/twitter/follow/splitsoftware.svg?style=social&label=Follow&maxAge=1529000)](https://twitter.com/intent/follow?screen_name=splitsoftware) 3 | 4 | ## Overview 5 | This Provider is designed to allow the use of OpenFeature with Split, the platform for controlled rollouts, serving features to your users via the Split feature flag to manage your complete customer experience. 6 | 7 | ## Compatibility 8 | It supports **Node.js version 14.x or later**. 9 | 10 | 11 | ## Getting started 12 | Below is a simple example that describes the instantiation of the Split Provider. Please see the [OpenFeature Documentation](https://docs.openfeature.dev/docs/reference/concepts/evaluation-api) for details on how to use the OpenFeature SDK. 13 | 14 | ### Add the Split provider 15 | 16 | ```sh 17 | npm install @splitsoftware/openfeature-js-split-provider 18 | ``` 19 | 20 | ### Confirm peer dependencies are installed 21 | ```sh 22 | npm install @splitsoftware/splitio 23 | npm install @openfeature/server-sdk 24 | ``` 25 | 26 | ### Register the Split provider with OpenFeature using sdk apiKey 27 | ```js 28 | const OpenFeature = require('@openfeature/server-sdk').OpenFeature; 29 | const OpenFeatureSplitProvider = require('@splitsoftware/openfeature-js-split-provider').OpenFeatureSplitProvider; 30 | 31 | const authorizationKey = 'your auth key' 32 | const provider = new OpenFeatureSplitProvider(authorizationKey); 33 | OpenFeature.setProvider(provider); 34 | ``` 35 | 36 | ### Register the Split provider with OpenFeature using splitFactory 37 | ```js 38 | const OpenFeature = require('@openfeature/server-sdk').OpenFeature; 39 | const SplitFactory = require('@splitsoftware/splitio').SplitFactory; 40 | const OpenFeatureSplitProvider = require('@splitsoftware/openfeature-js-split-provider').OpenFeatureSplitProvider; 41 | 42 | const authorizationKey = 'your auth key' 43 | const splitFactory = SplitFactory({core: {authorizationKey}}); 44 | const provider = new OpenFeatureSplitProvider(splitFactory); 45 | OpenFeature.setProvider(provider); 46 | ``` 47 | 48 | ### Register the Split provider with OpenFeature using splitClient 49 | ```js 50 | const OpenFeature = require('@openfeature/server-sdk').OpenFeature; 51 | const SplitFactory = require('@splitsoftware/splitio').SplitFactory; 52 | const OpenFeatureSplitProvider = require('@splitsoftware/openfeature-js-split-provider').OpenFeatureSplitProvider; 53 | 54 | const authorizationKey = 'your auth key' 55 | const splitClient = SplitFactory({core: {authorizationKey}}).client(); 56 | const provider = new OpenFeatureSplitProvider({splitClient}); 57 | OpenFeature.setProvider(provider); 58 | ``` 59 | 60 | ## Use of OpenFeature with Split 61 | After the initial setup you can use OpenFeature according to their [documentation](https://docs.openfeature.dev/docs/reference/concepts/evaluation-api/). 62 | 63 | One important note is that the Split Provider **requires a targeting key** to be set. Often times this should be set when evaluating the value of a flag by [setting an EvaluationContext](https://docs.openfeature.dev/docs/reference/concepts/evaluation-context) which contains the targeting key. An example flag evaluation is 64 | ```js 65 | const client = openFeature.getClient('CLIENT_NAME'); 66 | 67 | const context: EvaluationContext = { 68 | targetingKey: 'TARGETING_KEY', 69 | }; 70 | const boolValue = await client.getBooleanValue('boolFlag', false, context); 71 | ``` 72 | If the same targeting key is used repeatedly, the evaluation context may be set at the client level 73 | ```js 74 | const context: EvaluationContext = { 75 | targetingKey: 'TARGETING_KEY', 76 | }; 77 | client.setEvaluationContext(context) 78 | ``` 79 | or at the OpenFeatureAPI level 80 | ```js 81 | const context: EvaluationContext = { 82 | targetingKey: 'TARGETING_KEY', 83 | }; 84 | OpenFeatureAPI.getInstance().setCtx(context) 85 | ``` 86 | If the context was set at the client or api level, it is not required to provide it during flag evaluation. 87 | 88 | ## Evaluate with details 89 | Use the get*Details(...) APIs to get the value and rich context (variant, reason, error code, metadata). This provider includes the Split treatment config as a raw JSON string under flagMetadata["config"] 90 | 91 | ```js 92 | const booleanTreatment = await client.getBooleanDetails('boolFlag', false, context); 93 | 94 | const config = booleanTreatment.flagMetadata.config 95 | ``` 96 | 97 | ## Tracking 98 | 99 | To use track(eventName, context, details) you must provide: 100 | 101 | - A non-blank `eventName`. 102 | - A context with: 103 | - `targetingKey` (non-blank). 104 | - `trafficType` (string, e.g. "user" or "account"). 105 | 106 | Optional: 107 | 108 | - details with: 109 | - `value`: numeric event value (defaults to 0). 110 | - `properties`: map of attributes (prefer primitives: string/number/boolean/null). 111 | 112 | Example: 113 | ```js 114 | const context = { targetingKey: 'user-123', trafficType: 'account' } 115 | const details = { value: 19.99, plan: 'pro', coupon: 'WELCOME10' } 116 | 117 | client.track('checkout.completed', context, details) 118 | ``` 119 | ## Submitting issues 120 | 121 | The Split team monitors all issues submitted to this [issue tracker](https://github.com/splitio/split-openfeature-provider-nodejs/issues). We encourage you to use this issue tracker to submit any bug reports, feedback, and feature enhancements. We'll do our best to respond in a timely manner. 122 | 123 | ## Contributing 124 | Please see [Contributors Guide](CONTRIBUTORS-GUIDE.md) to find all you need to submit a Pull Request (PR). 125 | 126 | ## License 127 | Licensed under the Apache License, Version 2.0. See: [Apache License](http://www.apache.org/licenses/). 128 | 129 | ## About Split 130 | 131 | Split is the leading Feature Delivery Platform for engineering teams that want to confidently deploy features as fast as they can develop them. Split’s fine-grained management, real-time monitoring, and data-driven experimentation ensure that new features will improve the customer experience without breaking or degrading performance. Companies like Twilio, Salesforce, GoDaddy and WePay trust Split to power their feature delivery. 132 | 133 | To learn more about Split, contact hello@split.io, or get started with feature flags for free at https://www.split.io/signup. 134 | 135 | Split has built and maintains SDKs for: 136 | 137 | * .NET [Github](https://github.com/splitio/dotnet-client) [Docs](https://help.split.io/hc/en-us/articles/360020240172--NET-SDK) 138 | * Android [Github](https://github.com/splitio/android-client) [Docs](https://help.split.io/hc/en-us/articles/360020343291-Android-SDK) 139 | * Angular [Github](https://github.com/splitio/angular-sdk-plugin) [Docs](https://help.split.io/hc/en-us/articles/6495326064397-Angular-utilities) 140 | * Elixir thin-client [Github](https://github.com/splitio/elixir-thin-client) [Docs](https://help.split.io/hc/en-us/articles/26988707417869-Elixir-Thin-Client-SDK) 141 | * Flutter [Github](https://github.com/splitio/flutter-sdk-plugin) [Docs](https://help.split.io/hc/en-us/articles/8096158017165-Flutter-plugin) 142 | * GO [Github](https://github.com/splitio/go-client) [Docs](https://help.split.io/hc/en-us/articles/360020093652-Go-SDK) 143 | * iOS [Github](https://github.com/splitio/ios-client) [Docs](https://help.split.io/hc/en-us/articles/360020401491-iOS-SDK) 144 | * Java [Github](https://github.com/splitio/java-client) [Docs](https://help.split.io/hc/en-us/articles/360020405151-Java-SDK) 145 | * JavaScript [Github](https://github.com/splitio/javascript-client) [Docs](https://help.split.io/hc/en-us/articles/360020448791-JavaScript-SDK) 146 | * JavaScript for Browser [Github](https://github.com/splitio/javascript-browser-client) [Docs](https://help.split.io/hc/en-us/articles/360058730852-Browser-SDK) 147 | * Node.js [Github](https://github.com/splitio/javascript-client) [Docs](https://help.split.io/hc/en-us/articles/360020564931-Node-js-SDK) 148 | * PHP [Github](https://github.com/splitio/php-client) [Docs](https://help.split.io/hc/en-us/articles/360020350372-PHP-SDK) 149 | * PHP thin-client [Github](https://github.com/splitio/php-thin-client) [Docs](https://help.split.io/hc/en-us/articles/18305128673933-PHP-Thin-Client-SDK) 150 | * Python [Github](https://github.com/splitio/python-client) [Docs](https://help.split.io/hc/en-us/articles/360020359652-Python-SDK) 151 | * React [Github](https://github.com/splitio/react-client) [Docs](https://help.split.io/hc/en-us/articles/360038825091-React-SDK) 152 | * React Native [Github](https://github.com/splitio/react-native-client) [Docs](https://help.split.io/hc/en-us/articles/4406066357901-React-Native-SDK) 153 | * Redux [Github](https://github.com/splitio/redux-client) [Docs](https://help.split.io/hc/en-us/articles/360038851551-Redux-SDK) 154 | * Ruby [Github](https://github.com/splitio/ruby-client) [Docs](https://help.split.io/hc/en-us/articles/360020673251-Ruby-SDK) 155 | 156 | For a comprehensive list of open source projects visit our [Github page](https://github.com/splitio?utf8=%E2%9C%93&query=%20only%3Apublic%20). 157 | 158 | **Learn more about Split:** 159 | 160 | Visit [split.io/product](https://www.split.io/product) for an overview of Split, or visit our documentation at [help.split.io](http://help.split.io) for more detailed information. 161 | 162 | -------------------------------------------------------------------------------- /src/__tests__/nodeSuites/client.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable jest/no-conditional-expect */ 2 | import { OpenFeatureSplitProvider } from '../../lib/js-split-provider'; 3 | import { getLocalHostSplitClient, getSplitFactory } from '../testUtils'; 4 | 5 | import { OpenFeature } from '@openfeature/server-sdk'; 6 | 7 | const cases = [ 8 | [ 9 | 'openfeature client tests mode: splitClient', 10 | () => ({ splitClient: getLocalHostSplitClient()}), 11 | 12 | ], 13 | [ 14 | 'openfeature client tests mode: splitFactory', 15 | getSplitFactory 16 | ], 17 | ]; 18 | 19 | describe.each(cases)('%s', (label, getOptions) => { 20 | 21 | let client; 22 | let provider; 23 | let options; 24 | 25 | beforeEach(() => { 26 | 27 | options = getOptions(); 28 | provider = new OpenFeatureSplitProvider(options); 29 | OpenFeature.setProvider(provider); 30 | 31 | client = OpenFeature.getClient('test'); 32 | let evaluationContext = { 33 | targetingKey: 'key' 34 | }; 35 | client.setContext(evaluationContext); 36 | }); 37 | afterEach(async () => { 38 | await OpenFeature.close(); 39 | }); 40 | 41 | test('use default test', async () => { 42 | let flagName = 'random-non-existent-feature'; 43 | 44 | let result = await client.getBooleanValue(flagName, false); 45 | expect(result).toBe(false); 46 | 47 | let result2 = await client.getBooleanValue(flagName, true); 48 | expect(result2).toBe(true); 49 | 50 | let defaultString = 'blah'; 51 | let resultString = await client.getStringValue(flagName, defaultString); 52 | expect(resultString).toBe(defaultString); 53 | 54 | let defaultInt = 100; 55 | let resultInt = await client.getNumberValue(flagName, defaultInt); 56 | expect(resultInt).toBe(defaultInt); 57 | 58 | let defaultStructure = { 59 | foo: 'bar' 60 | }; 61 | let resultStructure = await client.getObjectValue(flagName, defaultStructure); 62 | expect(resultStructure).toEqual(defaultStructure); 63 | }); 64 | 65 | test('missing targetingKey test', async () => { 66 | let details = await client.getBooleanDetails('non-existent-feature', false, { targetingKey: undefined }); 67 | expect(details.value).toBe(false); 68 | expect(details.errorCode).toBe('TARGETING_KEY_MISSING'); 69 | }); 70 | 71 | test('evaluate Boolean control test', async () => { 72 | let details = await client.getBooleanDetails('non-existent-feature', false); 73 | expect(details.value).toBe(false); 74 | expect(details.errorCode).toBe('FLAG_NOT_FOUND'); 75 | expect(details.reason).toBe('ERROR'); 76 | }); 77 | 78 | test('evaluate Boolean test', async () => { 79 | let result = await client.getBooleanValue('some_other_feature', true); 80 | expect(result).toBe(false); 81 | }); 82 | 83 | test('evaluate Boolean details test', async () => { 84 | let result = await client.getBooleanDetails('my_feature', false); 85 | expect(result.value).toBe(true); 86 | expect(result.flagMetadata).toEqual({ config: '{"desc" : "this applies only to ON treatment"}' }); 87 | 88 | result = await client.getBooleanDetails('my_feature', true, { targetingKey: 'randomKey' }); 89 | expect(result.value).toBe(false); 90 | expect(result.flagMetadata).toEqual({ config: '' }); 91 | }); 92 | 93 | test('evaluate String test', async () => { 94 | let result = await client.getStringValue('some_other_feature', 'on'); 95 | expect(result).toBe('off'); 96 | }); 97 | 98 | test('evaluate String details test', async () => { 99 | let result = await client.getStringDetails('my_feature', 'off'); 100 | expect(result.value).toBe('on'); 101 | expect(result.flagMetadata).toEqual({ config: '{"desc" : "this applies only to ON treatment"}' }); 102 | 103 | result = await client.getStringDetails('my_feature', 'on', { targetingKey: 'randomKey' }); 104 | expect(result.value).toBe('off'); 105 | expect(result.flagMetadata).toEqual({ config: '' }); 106 | }); 107 | 108 | test('evaluate Number test', async () => { 109 | let result = await client.getNumberValue('int_feature', 0); 110 | expect(result).toBe(32); 111 | }); 112 | 113 | test('evaluate Object test', async () => { 114 | let result = await client.getObjectValue('obj_feature', {}); 115 | expect(result).toEqual({ key: 'value' }); 116 | }); 117 | 118 | test('evaluate Metadata name test', async () => { 119 | expect(client.metadata.name).toBe('test'); 120 | }); 121 | 122 | test('evaluate Boolean without details test', async () => { 123 | let details = await client.getBooleanDetails('some_other_feature', true); 124 | expect(details.flagKey).toBe('some_other_feature'); 125 | expect(details.reason).toBe('TARGETING_MATCH'); 126 | expect(details.value).toBe(false); 127 | expect(details.variant).toBe('off'); 128 | expect(details.errorCode).toBeUndefined(); 129 | }); 130 | 131 | test('evaluate Number details test', async () => { 132 | let details = await client.getNumberDetails('int_feature', 0); 133 | expect(details.flagKey).toBe('int_feature'); 134 | expect(details.reason).toBe('TARGETING_MATCH'); 135 | expect(details.value).toBe(32); 136 | expect(details.variant).toBe('32'); 137 | expect(details.errorCode).toBeUndefined(); 138 | }); 139 | 140 | test('evaluate String without details test', async () => { 141 | let details = await client.getStringDetails('some_other_feature', 'blah'); 142 | expect(details.flagKey).toBe('some_other_feature'); 143 | expect(details.reason).toBe('TARGETING_MATCH'); 144 | expect(details.value).toBe('off'); 145 | expect(details.variant).toBe('off'); 146 | expect(details.errorCode).toBeUndefined(); 147 | }); 148 | 149 | test('evaluate Object details test', async () => { 150 | let details = await client.getObjectDetails('obj_feature', {}); 151 | expect(details.flagKey).toBe('obj_feature'); 152 | expect(details.reason).toBe('TARGETING_MATCH'); 153 | expect(details.value).toEqual({ key: 'value' }); 154 | expect(details.variant).toBe('{"key": "value"}'); 155 | expect(details.errorCode).toBeUndefined(); 156 | }); 157 | 158 | test('evaluate Boolean fail test', async () => { 159 | let value = await client.getBooleanValue('obj_feature', false); 160 | expect(value).toBe(false); 161 | 162 | let details = await client.getBooleanDetails('obj_feature', false); 163 | expect(details.value).toBe(false); 164 | expect(details.errorCode).toBe('PARSE_ERROR'); 165 | expect(details.reason).toBe('ERROR'); 166 | expect(details.variant).toBeUndefined(); 167 | }); 168 | 169 | test('evaluate Number fail test', async () => { 170 | let value = await client.getNumberValue('obj_feature', 10); 171 | expect(value).toBe(10); 172 | 173 | let details = await client.getNumberDetails('obj_feature', 10); 174 | expect(details.value).toBe(10); 175 | expect(details.errorCode).toBe('PARSE_ERROR'); 176 | expect(details.reason).toBe('ERROR'); 177 | expect(details.variant).toBeUndefined(); 178 | }); 179 | 180 | test('evaluate Object fail test', async () => { 181 | let defaultObject = { foo: 'bar' }; 182 | let value = await client.getObjectValue('int_feature', defaultObject); 183 | expect(value).toEqual(defaultObject); 184 | 185 | let details = await client.getObjectDetails('int_feature', defaultObject); 186 | expect(details.value).toEqual(defaultObject); 187 | expect(details.errorCode).toBe('PARSE_ERROR'); 188 | expect(details.reason).toBe('ERROR'); 189 | expect(details.variant).toBeUndefined(); 190 | }); 191 | 192 | test('track: throws when missing eventName', async () => { 193 | try { 194 | await client.track('', { targetingKey: 'u1', trafficType: 'user' }, {}); 195 | } catch (e) { 196 | expect(e.message).toBe('Missing eventName, required to track'); 197 | expect(e.code).toBe('PARSE_ERROR'); 198 | } 199 | }); 200 | 201 | test('track: throws when missing targetingKey', async () => { 202 | try { 203 | await client.track('my-event', { trafficType: 'user' }, {}); 204 | } catch (e) { 205 | expect(e.message).toBe('Missing targetingKey, required to track'); 206 | expect(e.code).toBe('PARSE_ERROR'); 207 | } 208 | }); 209 | 210 | test('track: throws when missing trafficType', async () => { 211 | try { 212 | await client.track('my-event', { targetingKey: 'u1' }, {}); 213 | } catch (e) { 214 | expect(e.message).toBe('Missing trafficType variable, required to track'); 215 | expect(e.code).toBe('INVALID_CONTEXT'); 216 | } 217 | }); 218 | 219 | test('track: without value', async () => { 220 | const trackSpy = jest.spyOn(options.splitClient ? options.splitClient : options.client(), 'track'); 221 | await client.track('my-event', { targetingKey: 'u1', trafficType: 'user' }, { properties: { prop1: 'value1' } }); 222 | expect(trackSpy).toHaveBeenCalledWith('u1', 'user', 'my-event', undefined, { prop1: 'value1' }); 223 | }); 224 | 225 | test('track: with value', async () => { 226 | const trackSpy = jest.spyOn(options.splitClient ? options.splitClient : options.client(), 'track'); 227 | await client.track('my-event', { targetingKey: 'u1', trafficType: 'user' }, { value: 9.99, properties: { prop1: 'value1' } }); 228 | expect(trackSpy).toHaveBeenCalledWith('u1', 'user', 'my-event', 9.99, { prop1: 'value1' }); 229 | }); 230 | }); 231 | -------------------------------------------------------------------------------- /src/__tests__/nodeSuites/provider.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable jest/no-conditional-expect */ 2 | import { getLocalHostSplitClient, getSplitFactory } from '../testUtils'; 3 | import { OpenFeatureSplitProvider } from '../../lib/js-split-provider'; 4 | 5 | const cases = [ 6 | [ 7 | 'provider tests mode: splitClient', 8 | () => ({ splitClient: getLocalHostSplitClient()}), 9 | 10 | ], 11 | [ 12 | 'provider tests mode: splitFactory', 13 | getSplitFactory 14 | ], 15 | ]; 16 | 17 | describe.each(cases)('%s', (label, getOptions) => { 18 | 19 | let provider; 20 | let options; 21 | 22 | beforeEach(() => { 23 | options = getOptions(); 24 | provider = new OpenFeatureSplitProvider(options); 25 | }); 26 | 27 | afterEach(async () => { 28 | jest.clearAllMocks() 29 | await provider.onClose(); 30 | }); 31 | 32 | test('evaluate Boolean null/empty test', async () => { 33 | try { 34 | await provider.resolveBooleanEvaluation('', false, { targetingKey: 'user1' }); 35 | } catch (e) { 36 | expect(e.message).toBe('flagKey must be a non-empty string'); 37 | expect(e.code).toBe('FLAG_NOT_FOUND'); 38 | } 39 | }); 40 | 41 | test('evaluate Boolean control test', async () => { 42 | try { 43 | await provider.resolveBooleanEvaluation('non-existent-feature', false, { targetingKey: 'user1' }); 44 | } catch (e) { 45 | expect(e.message).toBe('Received the "control" value from Split.'); 46 | expect(e.code).toBe('FLAG_NOT_FOUND'); 47 | } 48 | }); 49 | 50 | test('evaluate Boolean true test', async () => { 51 | const details = await provider.resolveBooleanEvaluation('my_feature', false, { targetingKey: 'key' }); 52 | expect(details.value).toBe(true); 53 | expect(details.variant).toBe('on'); 54 | expect(details.reason).toBe('TARGETING_MATCH'); 55 | expect(details.flagMetadata).toEqual({ config: '{"desc" : "this applies only to ON treatment"}' }); 56 | }); 57 | 58 | test('evaluate Boolean on test', async () => { 59 | const details = await provider.resolveBooleanEvaluation('my_feature', true, { targetingKey: 'key' }); 60 | expect(details.value).toBe(true); 61 | expect(details.variant).toBe('on'); 62 | expect(details.reason).toBe('TARGETING_MATCH'); 63 | expect(details.flagMetadata).toEqual({ config: '{"desc" : "this applies only to ON treatment"}' }); 64 | }); 65 | 66 | test('evaluate Boolean false test', async () => { 67 | const details = await provider.resolveBooleanEvaluation('some_other_feature', true, { targetingKey: 'user1' }); 68 | expect(details.value).toBe(false); 69 | expect(details.variant).toBe('off'); 70 | expect(details.reason).toBe('TARGETING_MATCH'); 71 | expect(details.flagMetadata).toEqual({ config: '' }); 72 | }); 73 | 74 | test('evaluate Boolean off test', async () => { 75 | const details = await provider.resolveBooleanEvaluation('some_other_feature', false, { targetingKey: 'user1' }); 76 | expect(details.value).toBe(false); 77 | expect(details.variant).toBe('off'); 78 | expect(details.reason).toBe('TARGETING_MATCH'); 79 | expect(details.flagMetadata).toEqual({ config: '' }); 80 | }); 81 | 82 | test('evaluate Boolean error test', async () => { 83 | try { 84 | await provider.resolveBooleanEvaluation('int_feature', false, { targetingKey: 'user1' }); 85 | } catch (e) { 86 | expect(e.message).toBe('Invalid boolean value for 32'); 87 | expect(e.code).toBe('PARSE_ERROR'); 88 | } 89 | }); 90 | 91 | test('evaluate String null/empty test', async () => { 92 | try { 93 | await provider.resolveStringEvaluation('', 'default', { targetingKey: 'user1' }); 94 | } catch (e) { 95 | expect(e.message).toBe('flagKey must be a non-empty string'); 96 | expect(e.code).toBe('FLAG_NOT_FOUND'); 97 | } 98 | }); 99 | 100 | test('evaluate String control test', async () => { 101 | try { 102 | await provider.resolveStringEvaluation('non-existent-feature', 'default', { targetingKey: 'user1' }); 103 | } catch (e) { 104 | expect(e.message).toBe('Received the "control" value from Split.'); 105 | expect(e.code).toBe('FLAG_NOT_FOUND'); 106 | } 107 | }); 108 | 109 | test('evaluate String regular test', async () => { 110 | try { 111 | await provider.resolveStringEvaluation('string_feature', 'default', { targetingKey: 'user1' }); 112 | } catch (e) { 113 | expect(e.message).toBe('Received the "control" value from Split.'); 114 | expect(e.code).toBe('FLAG_NOT_FOUND'); 115 | } 116 | }); 117 | 118 | test('evaluate String error test', async () => { 119 | try { 120 | await provider.resolveStringEvaluation('int_feature', 'default', { targetingKey: 'user1' }); 121 | } catch (e) { 122 | expect(e.message).toBe('Invalid string value for 32'); 123 | expect(e.code).toBe('PARSE_ERROR'); 124 | } 125 | }); 126 | 127 | test('evaluate Number null/empty test', async () => { 128 | try { 129 | await provider.resolveNumberEvaluation('', 0, { targetingKey: 'user1' }); 130 | } catch (e) { 131 | expect(e.message).toBe('flagKey must be a non-empty string'); 132 | expect(e.code).toBe('FLAG_NOT_FOUND'); 133 | } 134 | }); 135 | 136 | test('evaluate Number control test', async () => { 137 | try { 138 | await provider.resolveNumberEvaluation('non-existent-feature', 0, { targetingKey: 'user1' }); 139 | } catch (e) { 140 | expect(e.message).toBe('Received the "control" value from Split.'); 141 | expect(e.code).toBe('FLAG_NOT_FOUND'); 142 | } 143 | }); 144 | 145 | test('evaluate Number regular test', async () => { 146 | const details = await provider.resolveNumberEvaluation('int_feature', 0, { targetingKey: 'user1' }); 147 | expect(details.value).toBe(32); 148 | expect(details.variant).toBe('32'); 149 | expect(details.reason).toBe('TARGETING_MATCH'); 150 | expect(details.flagMetadata).toEqual({ config: '{"desc" : "this applies only to number treatment"}' }); 151 | }); 152 | 153 | test('evaluate Number error test', async () => { 154 | try { 155 | await provider.resolveNumberEvaluation('my_feature', 0, { targetingKey: 'user1' }); 156 | } catch (e) { 157 | expect(e.message).toBe('Invalid numeric value off'); 158 | expect(e.code).toBe('PARSE_ERROR'); 159 | } 160 | }); 161 | 162 | test('evaluate Structure null/empty test', async () => { 163 | try { 164 | await provider.resolveObjectEvaluation('', {}, { targetingKey: 'user1' }); 165 | } catch (e) { 166 | expect(e.message).toBe('flagKey must be a non-empty string'); 167 | expect(e.code).toBe('FLAG_NOT_FOUND'); 168 | } 169 | }); 170 | 171 | test('evaluate Structure control test', async () => { 172 | try { 173 | await provider.resolveObjectEvaluation('non-existent-feature', {}, { targetingKey: 'user1' }); 174 | } catch (e) { 175 | expect(e.message).toBe('Received the "control" value from Split.'); 176 | expect(e.code).toBe('FLAG_NOT_FOUND'); 177 | } 178 | }); 179 | 180 | test('evaluate Structure regular test', async () => { 181 | const details = await provider.resolveObjectEvaluation('obj_feature', {}, { targetingKey: 'user1' }); 182 | expect(details.value).toEqual({ key: 'value' }); 183 | expect(details.variant).toBe('{"key": "value"}'); 184 | expect(details.reason).toBe('TARGETING_MATCH'); 185 | expect(details.flagMetadata).toEqual({ config: '{"desc" : "this applies only to obj treatment"}' }); 186 | }); 187 | 188 | test('evaluate Structure error test', async () => { 189 | try { 190 | await provider.resolveObjectEvaluation('int_feature', {}, { targetingKey: 'user1' }); 191 | } catch (e) { 192 | expect(e.message).toBe('Error parsing 32 as JSON, ParseError: Flag value 32 had unexpected type number, expected "object"'); 193 | expect(e.code).toBe('PARSE_ERROR'); 194 | } 195 | }); 196 | 197 | test('track: throws when missing eventName', async () => { 198 | try { 199 | await provider.track('', { targetingKey: 'u1', trafficType: 'user' }, {}); 200 | } catch (e) { 201 | expect(e.message).toBe('Missing eventName, required to track'); 202 | expect(e.code).toBe('PARSE_ERROR'); 203 | } 204 | }); 205 | 206 | test('track: throws when missing trafficType', async () => { 207 | try { 208 | await provider.track('evt', { targetingKey: 'u1' }, {}); 209 | } catch (e) { 210 | expect(e.message).toBe('Missing trafficType variable, required to track'); 211 | expect(e.code).toBe('INVALID_CONTEXT'); 212 | } 213 | }); 214 | 215 | test('track: throws when missing targetingKey', async () => { 216 | try { 217 | await provider.track('evt', { trafficType: 'user' }, {}); 218 | } catch (e) { 219 | expect(e.message).toBe('Missing targetingKey, required to track'); 220 | expect(e.code).toBe('TARGETING_KEY_MISSING'); 221 | } 222 | }); 223 | 224 | test('track: ok without details', async () => { 225 | const trackSpy = jest.spyOn(options.splitClient ? options.splitClient : options.client(), 'track'); 226 | await provider.track('view', { targetingKey: 'u1', trafficType: 'user' }, null); 227 | expect(trackSpy).toHaveBeenCalledTimes(1); 228 | expect(trackSpy).toHaveBeenCalledWith('u1', 'user', 'view', undefined, {}); 229 | }); 230 | 231 | test('track: ok with details', async () => { 232 | const trackSpy = jest.spyOn(options.splitClient ? options.splitClient : options.client(), 'track'); 233 | await provider.track( 234 | 'purchase', 235 | { targetingKey: 'u1', trafficType: 'user' }, 236 | { value: 9.99, properties: { plan: 'pro', beta: true } } 237 | ); 238 | expect(trackSpy).toHaveBeenCalledTimes(1); 239 | expect(trackSpy).toHaveBeenCalledWith('u1', 'user', 'purchase', 9.99, { plan: 'pro', beta: true }); 240 | }); 241 | }); 242 | -------------------------------------------------------------------------------- /scripts/sdk.unit.testing.setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict'; 4 | 5 | const exec = require('child_process').execSync; 6 | 7 | // SETUP THE ENVIRONMENT HERE! 8 | const orgId = 'e9dd7c90-0110-11e6-8aff-cea8d6f43ae4'; // Google 9 | const environment = 'eab73ac0-0110-11e6-8aff-cea8d6f43ae4'; // production 10 | const TOKEN = 'ZDA1Y2JmNjAtMzI0My00ZmMwLWIxODUtMWIzYWY5ZTE0M2M3'; // woow 11 | const SDK_TOKEN = 'lu78o1osahssej3bevbnqkkvfr1ahjoj706v'; // production 12 | // SETUP THE ENVIRONMENT HERE! 13 | 14 | function createTestCurl(spec) { 15 | return `curl 'https://api.split.io/internal/api/tests' -H 'Authorization: Bearer ${TOKEN}' -H 'Content-Type: application/json' --data-binary '${JSON.stringify(spec)}'`; 16 | } 17 | 18 | //------------------------------------------------------------------------------ 19 | 20 | const user_attr_eq_10 = { 21 | name: 'user_attr_eq_ten', 22 | description: 'user.attr = 10', 23 | definition: 'if user.attr = 10 then split 100:on', 24 | treatments: [ 25 | { name: 'on', 26 | includes: [], 27 | description: null }, 28 | { name: 'off', 29 | includes: [], 30 | description: null } 31 | ], 32 | environment, 33 | orgId 34 | }; 35 | 36 | const user_attr_eq_number_10 = { 37 | name: 'user_attr_eq_number_ten', 38 | description: 'user.attr = number 10', 39 | definition: 'if user.attr = number 10 then split 100:on', 40 | treatments: [ 41 | { name: 'on', 42 | includes: [], 43 | description: null }, 44 | { name: 'off', 45 | includes: [], 46 | description: null } 47 | ], 48 | environment, 49 | orgId 50 | }; 51 | 52 | // new Date(1458240947021) 53 | // Thu Mar 17 2016 15:55:47 GMT-0300 (ART) 54 | const user_attr_eq_datetime_1458240947021 = { 55 | name: 'user_attr_eq_datetime_1458240947021', 56 | description: 'user.attr = datetime 1458240947021', 57 | definition: 'if user.attr = datetime 1458240947021 then split 100:on', 58 | treatments: [ 59 | { name: 'on', 60 | includes: [], 61 | description: null }, 62 | { name: 'off', 63 | includes: [], 64 | description: null } 65 | ], 66 | environment, 67 | orgId 68 | }; 69 | 70 | //------------------------------------------------------------------------------ 71 | 72 | const user_attr_gte_10 = { 73 | name: 'user_attr_gte_10', 74 | description: 'user.attr >= 10', 75 | definition: 'if user.attr >= 10 then split 100:on', 76 | treatments: [ 77 | { name: 'on', 78 | includes: [], 79 | description: null }, 80 | { name: 'off', 81 | includes: [], 82 | description: null } 83 | ], 84 | environment, 85 | orgId 86 | }; 87 | 88 | const user_attr_gte_number_10 = { 89 | name: 'user_attr_gte_number_10', 90 | description: 'user.attr >= number 10', 91 | definition: 'if user.attr >= number 10 then split 100:on', 92 | treatments: [ 93 | { name: 'on', 94 | includes: [], 95 | description: null }, 96 | { name: 'off', 97 | includes: [], 98 | description: null } 99 | ], 100 | environment, 101 | orgId 102 | }; 103 | 104 | const user_attr_gte_datetime_1458240947021 = { 105 | name: 'user_attr_gte_datetime_1458240947021', 106 | description: 'user.attr >= datetime 1458240947021', 107 | definition: 'if user.attr >= datetime 1458240947021 then split 100:on', 108 | treatments: [ 109 | { name: 'on', 110 | includes: [], 111 | description: null }, 112 | { name: 'off', 113 | includes: [], 114 | description: null } 115 | ], 116 | environment, 117 | orgId 118 | }; 119 | 120 | //------------------------------------------------------------------------------ 121 | 122 | const user_attr_lte_10 = { 123 | name: 'user_attr_lte_10', 124 | description: 'user.attr <= 10', 125 | definition: 'if user.attr <= 10 then split 100:on', 126 | treatments: [ 127 | { name: 'on', 128 | includes: [], 129 | description: null }, 130 | { name: 'off', 131 | includes: [], 132 | description: null } 133 | ], 134 | environment, 135 | orgId 136 | }; 137 | 138 | const user_attr_lte_number_10 = { 139 | name: 'user_attr_lte_number_10', 140 | description: 'user.attr <= number 10', 141 | definition: 'if user.attr <= number 10 then split 100:on', 142 | treatments: [ 143 | { name: 'on', 144 | includes: [], 145 | description: null }, 146 | { name: 'off', 147 | includes: [], 148 | description: null } 149 | ], 150 | environment, 151 | orgId 152 | }; 153 | 154 | const user_attr_lte_datetime_1458240947021 = { 155 | name: 'user_attr_lte_datetime_1458240947021', 156 | description: 'user.attr <= datetime 1458240947021', 157 | definition: 'if user.attr <= datetime 1458240947021 then split 100:on', 158 | treatments: [ 159 | { name: 'on', 160 | includes: [], 161 | description: null }, 162 | { name: 'off', 163 | includes: [], 164 | description: null } 165 | ], 166 | environment, 167 | orgId 168 | }; 169 | 170 | //------------------------------------------------------------------------------ 171 | 172 | const user_attr_btw_10_and_20 = { 173 | name: 'user_attr_btw_10_and_20', 174 | description: 'user.attr between 10 and 20', 175 | definition: 'if user.attr is between 10 and 20 then split 100:on', 176 | treatments: [ 177 | { name: 'on', 178 | includes: [], 179 | description: null }, 180 | { name: 'off', 181 | includes: [], 182 | description: null } 183 | ], 184 | environment, 185 | orgId 186 | }; 187 | 188 | const user_attr_btw_number_10_and_20 = { 189 | name: 'user_attr_btw_number_10_and_20', 190 | description: 'user.attr between number 10 and 20', 191 | definition: 'if user.attr is between number 10 and 20 then split 100:on', 192 | treatments: [ 193 | { name: 'on', 194 | includes: [], 195 | description: null }, 196 | { name: 'off', 197 | includes: [], 198 | description: null } 199 | ], 200 | environment, 201 | orgId 202 | }; 203 | 204 | 205 | // new Date(1458240947021) 206 | // Thu Mar 17 2016 15:55:47 GMT-0300 (ART) 207 | // new Date(1458246884077) 208 | // Thu Mar 17 2016 17:34:44 GMT-0300 (ART) 209 | const user_attr_btw_datetime_1458240947021_and_1458246884077 = { 210 | name: 'user_attr_btw_datetime_1458240947021_and_1458246884077', 211 | description: 'user.attr between datetime 1458240947021 and 1458246884077', 212 | definition: 'if user.attr is between datetime 1458240947021 and 1458246884077 then split 100:on', 213 | treatments: [ 214 | { name: 'on', 215 | includes: [], 216 | description: null }, 217 | { name: 'off', 218 | includes: [], 219 | description: null } 220 | ], 221 | environment, 222 | orgId 223 | }; 224 | 225 | //------------------------------------------------------------------------------ 226 | 227 | const user_account_in_segment_all = { 228 | name: 'user_account_in_segment_all', 229 | description: 'user.account in segment all', 230 | definition: 'if user.account is in segment all then split 100:on', 231 | treatments: [ 232 | { name: 'on', 233 | includes: [], 234 | description: null }, 235 | { name: 'off', 236 | includes: [], 237 | description: null } 238 | ], 239 | environment, 240 | orgId 241 | }; 242 | 243 | const user_account_in_segment_employees = { 244 | name: 'user_account_in_segment_employees', 245 | description: 'user.account in segment employees', 246 | definition: 'if user.account is in segment employees then split 100:on', 247 | treatments: [ 248 | { name: 'on', 249 | includes: [], 250 | description: null }, 251 | { name: 'off', 252 | includes: [], 253 | description: null } 254 | ], 255 | environment, 256 | orgId 257 | }; 258 | 259 | const user_account_in_whitelist = { 260 | name: 'user_account_in_whitelist', 261 | description: 'user.account in whitelist', 262 | definition: 'if user.account is in list ["key_1@split.io", "key_2@split.io", "key_3@split.io", "key_4@split.io", "key_5@split.io"] then split 100:on', 263 | treatments: [ 264 | { name: 'on', 265 | includes: [], 266 | description: null }, 267 | { name: 'off', 268 | includes: [], 269 | description: null } 270 | ], 271 | environment, 272 | orgId 273 | }; 274 | 275 | //------------------------------------------------------------------------------ 276 | 277 | const user_between_dates_or_admin = { 278 | name: 'user_between_dates_or_admin', 279 | description: 'user between dates or is in admin segment', 280 | definition: ` 281 | if user.time is between datetime 1458240947021 and 1458246884077 then split 100:on 282 | else if user is in segment admin then 100:on 283 | `, 284 | treatments: [ 285 | { name: 'on', 286 | includes: [], 287 | description: null }, 288 | { name: 'off', 289 | includes: [], 290 | description: null } 291 | ], 292 | environment, 293 | orgId 294 | }; 295 | 296 | //------------------------------------------------------------------------------ 297 | 298 | const user_attr_gte_10_and_user_attr2_is_not_foo = { 299 | name: 'user_attr_gte_10_and_user_attr2_is_not_foo', 300 | description: 'user.attr >= 10 and user.attr2 not foo', 301 | definition: ` 302 | if user.attr >= 10 and user.attr2 is not in list ["foo"] then split 100%:on 303 | `, 304 | treatments: [ 305 | { name: 'on', 306 | includes: [], 307 | description: null }, 308 | { name: 'off', 309 | includes: [], 310 | description: null } 311 | ], 312 | environment, 313 | orgId 314 | }; 315 | 316 | //------------------------------------------------------------------------------ 317 | 318 | // Loop over each item in the array an hit the backend 319 | [ 320 | user_attr_eq_10, 321 | user_attr_eq_number_10, 322 | user_attr_eq_datetime_1458240947021, 323 | user_attr_gte_10, 324 | user_attr_gte_number_10, 325 | user_attr_gte_datetime_1458240947021, 326 | user_attr_lte_10, 327 | user_attr_lte_number_10, 328 | user_attr_lte_datetime_1458240947021, 329 | user_attr_btw_10_and_20, 330 | user_attr_btw_number_10_and_20, 331 | user_attr_btw_datetime_1458240947021_and_1458246884077, 332 | user_account_in_segment_all, 333 | user_account_in_segment_employees, 334 | user_account_in_whitelist, 335 | user_between_dates_or_admin, 336 | user_attr_gte_10_and_user_attr2_is_not_foo 337 | ].forEach(function (body) { 338 | let cmd = createTestCurl(body); 339 | console.log(cmd); 340 | exec(cmd); 341 | }); 342 | --------------------------------------------------------------------------------