├── .github └── workflows │ ├── publish.yml │ └── pull_request.yaml ├── .gitignore ├── .gitmodules ├── .husky └── pre-commit ├── .npmignore ├── .prettierignore ├── .prettierrc.cjs ├── CONTRIBUTING.md ├── LICENCE ├── README.md ├── flagsmith-engine ├── environments │ ├── models.ts │ └── util.ts ├── features │ ├── constants.ts │ ├── models.ts │ └── util.ts ├── identities │ ├── models.ts │ ├── traits │ │ └── models.ts │ └── util.ts ├── index.ts ├── organisations │ ├── models.ts │ └── util.ts ├── projects │ ├── models.ts │ └── util.ts ├── segments │ ├── constants.ts │ ├── evaluators.ts │ ├── models.ts │ └── util.ts └── utils │ ├── collections.ts │ ├── errors.ts │ ├── hashing │ └── index.ts │ └── index.ts ├── index.ts ├── package-lock.json ├── package.json ├── sdk ├── analytics.ts ├── errors.ts ├── index.ts ├── models.ts ├── offline_handlers.ts ├── polling_manager.ts ├── types.ts └── utils.ts ├── tests ├── engine │ ├── e2e │ │ └── engine.test.ts │ └── unit │ │ ├── engine.test.ts │ │ ├── environments │ │ ├── builder.test.ts │ │ └── models.test.ts │ │ ├── features │ │ └── models.test.ts │ │ ├── identities │ │ ├── identities_builders.test.ts │ │ └── identities_models.test.ts │ │ ├── organization │ │ └── models.test.ts │ │ ├── segments │ │ ├── segment_evaluators.test.ts │ │ ├── segments_model.test.ts │ │ └── util.ts │ │ ├── utils.ts │ │ └── utils │ │ └── utils.test.ts └── sdk │ ├── analytics.test.ts │ ├── data │ ├── environment.json │ ├── flags.json │ ├── identities.json │ ├── identity-with-transient-traits.json │ ├── offline-environment.json │ └── transient-identity.json │ ├── flagsmith-cache.test.ts │ ├── flagsmith-environment-flags.test.ts │ ├── flagsmith-identity-flags.test.ts │ ├── flagsmith.test.ts │ ├── offline-handlers.test.ts │ ├── polling.test.ts │ └── utils.ts ├── tsconfig.cjs.json ├── tsconfig.esm.json ├── tsconfig.json └── vitest.config.ts /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish NPM Package 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | package: 10 | runs-on: ubuntu-latest 11 | name: Publish NPM Package 12 | 13 | steps: 14 | - name: Cloning repo 15 | uses: actions/checkout@v3 16 | 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: '18.x' 20 | registry-url: 'https://registry.npmjs.org' 21 | 22 | - run: npm ci 23 | - run: npm run deploy 24 | env: 25 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 26 | -------------------------------------------------------------------------------- /.github/workflows/pull_request.yaml: -------------------------------------------------------------------------------- 1 | name: Unit/Integration Tests 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | - synchronize 8 | - reopened 9 | - ready_for_review 10 | push: 11 | branches: 12 | - main 13 | jobs: 14 | build-and-test: 15 | strategy: 16 | matrix: 17 | node-version: [18.x, 20.x, 22.x] 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | with: 22 | submodules: true 23 | - uses: actions/setup-node@v4 24 | with: 25 | node-version: "${{ matrix.node-version }}" 26 | - name: cache node modules 27 | uses: actions/cache@v4 28 | with: 29 | path: ~/.npm # npm cache files are stored in `~/.npm` on Linux/macOS 30 | key: npm-${{ matrix.node-version }}-${{ hashFiles('package-lock.json') }} 31 | restore-keys: | 32 | npm-${{ matrix.node-version }}-${{ hashFiles('package-lock.json') }} 33 | npm- 34 | - run: npm ci 35 | - run: npm test 36 | env: 37 | CI: true 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/* 2 | .vscode/settings.json 3 | .vscode/tasks.json 4 | .vscode/launch.json 5 | .vscode/extensions.json 6 | 7 | .idea/* 8 | 9 | node_modules/ 10 | build/ 11 | coverage/ 12 | 13 | .tool-versions 14 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "tests/engine/engine-tests/engine-test-data"] 2 | path = tests/engine/engine-tests/engine-test-data 3 | url = git@github.com:Flagsmith/engine-test-data.git 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm run lint 5 | git add ./flagsmith-engine ./sdk ./tests ./index.ts ./.github 6 | npm run test -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | ./.idea 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.json -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | bracketSpacing: true, 3 | printWidth: 100, 4 | singleQuote: true, 5 | tabWidth: 4, 6 | trailingComma: 'none', 7 | useTabs: false, 8 | arrowParens: 'avoid' 9 | }; 10 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We're always looking to improve this project, open source contribution is encouraged so long as they adhere to our guidelines. 4 | 5 | # Pull Requests 6 | 7 | The Solid State team will be monitoring for pull requests. When we get one, a member of team will test the work against our internal uses and sign off on the changes. From here, we'll either merge the pull request or provide feedback suggesting the next steps. 8 | 9 | **A couple things to keep in mind:** 10 | 11 | - If you've changed APIs, update the documentation. 12 | - Keep the code style (indents, wrapping) consistent. 13 | - If your PR involves a lot of commits, squash them using `git rebase -i` as this makes it easier for us to review. 14 | - Keep lines under 80 characters. 15 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Bullet Train Ltd. A UK company. 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 4 | 5 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | 7 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | 9 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 10 | 11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Flagsmith NodeJS Client 4 | 5 | [![npm version](https://badge.fury.io/js/flagsmith-nodejs.svg)](https://badge.fury.io/js/flagsmith-nodejs) 6 | [![](https://data.jsdelivr.com/v1/package/npm/flagsmith-nodejs/badge)](https://www.jsdelivr.com/package/npm/flagsmith-nodejs) 7 | 8 | The SDK clients for NodeJS [https://www.flagsmith.com/](https://www.flagsmith.com/). Flagsmith allows you to manage feature flags and remote config across multiple projects, environments and organisations. 9 | 10 | ## Adding to your project 11 | 12 | For full documentation visit [https://docs.flagsmith.com/clients/server-side](https://docs.flagsmith.com/clients/server-side). 13 | 14 | ## Contributing 15 | 16 | Please read [CONTRIBUTING.md](https://gist.github.com/kyle-ssg/c36a03aebe492e45cbd3eefb21cb0486) for details on our code of conduct, and the process for submitting pull requests 17 | 18 | ## Getting Help 19 | 20 | If you encounter a bug or feature request we would like to hear about it. Before you submit an issue please search existing issues in order to prevent duplicates. 21 | 22 | ## Testing 23 | 24 | To run the local tests you need to run following command beforehand: 25 | 26 | ```bash 27 | git submodule add git@github.com:Flagsmith/engine-test-data.git tests/engine/engine-tests/engine-test-data/ 28 | ``` 29 | 30 | ## Get in touch 31 | 32 | If you have any questions about our projects you can email support@flagsmith.com. 33 | 34 | ## Useful links 35 | 36 | [Website](https://www.flagsmith.com/) 37 | 38 | [Documentation](https://docs.flagsmith.com/) 39 | -------------------------------------------------------------------------------- /flagsmith-engine/environments/models.ts: -------------------------------------------------------------------------------- 1 | import { FeatureStateModel } from '../features/models.js'; 2 | import { IdentityModel } from '../identities/models.js'; 3 | import { ProjectModel } from '../projects/models.js'; 4 | 5 | export class EnvironmentAPIKeyModel { 6 | id: number; 7 | key: string; 8 | createdAt: number; 9 | name: string; 10 | clientApiKey: string; 11 | expiresAt?: number; 12 | active = true; 13 | 14 | constructor( 15 | id: number, 16 | key: string, 17 | createdAt: number, 18 | name: string, 19 | clientApiKey: string, 20 | expiresAt?: number 21 | ) { 22 | this.id = id; 23 | this.key = key; 24 | this.createdAt = createdAt; 25 | this.name = name; 26 | this.clientApiKey = clientApiKey; 27 | this.expiresAt = expiresAt; 28 | } 29 | 30 | isValid() { 31 | return !!this.active && (!this.expiresAt || this.expiresAt > Date.now()); 32 | } 33 | } 34 | 35 | export class EnvironmentModel { 36 | id: number; 37 | apiKey: string; 38 | project: ProjectModel; 39 | featureStates: FeatureStateModel[] = []; 40 | identityOverrides: IdentityModel[] = []; 41 | 42 | constructor(id: number, apiKey: string, project: ProjectModel) { 43 | this.id = id; 44 | this.apiKey = apiKey; 45 | this.project = project; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /flagsmith-engine/environments/util.ts: -------------------------------------------------------------------------------- 1 | import { buildFeatureStateModel } from '../features/util.js'; 2 | import { buildIdentityModel } from '../identities/util.js'; 3 | import { buildProjectModel } from '../projects/util.js'; 4 | import { EnvironmentAPIKeyModel, EnvironmentModel } from './models.js'; 5 | 6 | export function buildEnvironmentModel(environmentJSON: any) { 7 | const project = buildProjectModel(environmentJSON.project); 8 | const featureStates = environmentJSON.feature_states.map((fs: any) => 9 | buildFeatureStateModel(fs) 10 | ); 11 | const environmentModel = new EnvironmentModel( 12 | environmentJSON.id, 13 | environmentJSON.api_key, 14 | project 15 | ); 16 | environmentModel.featureStates = featureStates; 17 | if (!!environmentJSON.identity_overrides) { 18 | environmentModel.identityOverrides = environmentJSON.identity_overrides.map((identityData: any) => 19 | buildIdentityModel(identityData) 20 | ); 21 | } 22 | return environmentModel; 23 | } 24 | 25 | export function buildEnvironmentAPIKeyModel(apiKeyJSON: any): EnvironmentAPIKeyModel { 26 | const model = new EnvironmentAPIKeyModel( 27 | apiKeyJSON.id, 28 | apiKeyJSON.key, 29 | Date.parse(apiKeyJSON.created_at), 30 | apiKeyJSON.name, 31 | apiKeyJSON.client_api_key 32 | ); 33 | 34 | return model; 35 | } 36 | -------------------------------------------------------------------------------- /flagsmith-engine/features/constants.ts: -------------------------------------------------------------------------------- 1 | export const CONSTANTS = { 2 | STANDARD: 'STANDARD', 3 | MULTIVARIATE: 'MULTIVARIATE' 4 | }; 5 | -------------------------------------------------------------------------------- /flagsmith-engine/features/models.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID as uuidv4 } from "node:crypto"; 2 | import { getHashedPercentateForObjIds } from '../utils/hashing/index.js'; 3 | 4 | export class FeatureModel { 5 | id: number; 6 | name: string; 7 | type: string; 8 | 9 | constructor(id: number, name: string, type: string) { 10 | this.id = id; 11 | this.name = name; 12 | this.type = type; 13 | } 14 | 15 | eq(other: FeatureModel) { 16 | return !!other && this.id === other.id; 17 | } 18 | } 19 | 20 | export class MultivariateFeatureOptionModel { 21 | value: any; 22 | id: number | undefined; 23 | 24 | constructor(value: any, id?: number) { 25 | this.value = value; 26 | this.id = id; 27 | } 28 | } 29 | 30 | export class MultivariateFeatureStateValueModel { 31 | multivariateFeatureOption: MultivariateFeatureOptionModel; 32 | percentageAllocation: number; 33 | id: number; 34 | mvFsValueUuid: string = uuidv4(); 35 | 36 | constructor( 37 | multivariate_feature_option: MultivariateFeatureOptionModel, 38 | percentage_allocation: number, 39 | id: number, 40 | mvFsValueUuid?: string 41 | ) { 42 | this.id = id; 43 | this.percentageAllocation = percentage_allocation; 44 | this.multivariateFeatureOption = multivariate_feature_option; 45 | this.mvFsValueUuid = mvFsValueUuid || this.mvFsValueUuid; 46 | } 47 | } 48 | 49 | export class FeatureStateModel { 50 | feature: FeatureModel; 51 | enabled: boolean; 52 | djangoID: number; 53 | featurestateUUID: string = uuidv4(); 54 | featureSegment?: FeatureSegment; 55 | private value: any; 56 | multivariateFeatureStateValues: MultivariateFeatureStateValueModel[] = []; 57 | 58 | constructor( 59 | feature: FeatureModel, 60 | enabled: boolean, 61 | djangoID: number, 62 | value?: any, 63 | featurestateUuid: string = uuidv4() 64 | ) { 65 | this.feature = feature; 66 | this.enabled = enabled; 67 | this.djangoID = djangoID; 68 | this.value = value; 69 | this.featurestateUUID = featurestateUuid; 70 | } 71 | 72 | setValue(value: any) { 73 | this.value = value; 74 | } 75 | 76 | getValue(identityId?: number | string) { 77 | if (!!identityId && this.multivariateFeatureStateValues.length > 0) { 78 | return this.getMultivariateValue(identityId); 79 | } 80 | return this.value; 81 | } 82 | 83 | /* 84 | Returns `True` if `this` is higher segment priority than `other` 85 | (i.e. has lower value for featureSegment.priority) 86 | NOTE: 87 | A segment will be considered higher priority only if: 88 | 1. `other` does not have a feature segment(i.e: it is an environment feature state or it's a 89 | feature state with feature segment but from an old document that does not have `featureSegment.priority`) 90 | but `this` does. 91 | 2. `other` have a feature segment with high priority 92 | */ 93 | isHigherSegmentPriority(other: FeatureStateModel): boolean { 94 | if (!other.featureSegment || !this.featureSegment) { 95 | return !!this.featureSegment && !other.featureSegment; 96 | } 97 | return this.featureSegment.priority < other.featureSegment.priority; 98 | } 99 | 100 | getMultivariateValue(identityID: number | string) { 101 | let percentageValue: number | undefined; 102 | let startPercentage = 0; 103 | const sortedF = this.multivariateFeatureStateValues.sort((a, b) => { 104 | return a.id - b.id; 105 | }); 106 | for (const myValue of sortedF) { 107 | switch (myValue.percentageAllocation) { 108 | case 0: 109 | continue; 110 | case 100: 111 | return myValue.multivariateFeatureOption.value; 112 | default: 113 | if (percentageValue === undefined) { 114 | percentageValue = getHashedPercentateForObjIds([ 115 | this.djangoID || this.featurestateUUID, 116 | identityID 117 | ]); 118 | } 119 | } 120 | const limit = myValue.percentageAllocation + startPercentage; 121 | if (startPercentage <= percentageValue && percentageValue < limit) { 122 | return myValue.multivariateFeatureOption.value; 123 | } 124 | startPercentage = limit; 125 | } 126 | return this.value; 127 | } 128 | } 129 | 130 | export class FeatureSegment { 131 | priority: number; 132 | 133 | constructor(priority: number) { 134 | this.priority = priority; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /flagsmith-engine/features/util.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FeatureModel, 3 | FeatureSegment, 4 | FeatureStateModel, 5 | MultivariateFeatureOptionModel, 6 | MultivariateFeatureStateValueModel 7 | } from './models.js'; 8 | 9 | export function buildFeatureModel(featuresModelJSON: any): FeatureModel { 10 | return new FeatureModel(featuresModelJSON.id, featuresModelJSON.name, featuresModelJSON.type); 11 | } 12 | 13 | export function buildFeatureStateModel(featuresStateModelJSON: any): FeatureStateModel { 14 | const featureStateModel = new FeatureStateModel( 15 | buildFeatureModel(featuresStateModelJSON.feature), 16 | featuresStateModelJSON.enabled, 17 | featuresStateModelJSON.django_id, 18 | featuresStateModelJSON.feature_state_value, 19 | featuresStateModelJSON.featurestate_uuid 20 | ); 21 | 22 | featureStateModel.featureSegment = featuresStateModelJSON.feature_segment ? 23 | buildFeatureSegment(featuresStateModelJSON.feature_segment) : 24 | undefined; 25 | 26 | const multivariateFeatureStateValues = featuresStateModelJSON.multivariate_feature_state_values 27 | ? featuresStateModelJSON.multivariate_feature_state_values.map((fsv: any) => { 28 | const featureOption = new MultivariateFeatureOptionModel( 29 | fsv.multivariate_feature_option.value, 30 | fsv.multivariate_feature_option.id 31 | ); 32 | return new MultivariateFeatureStateValueModel( 33 | featureOption, 34 | fsv.percentage_allocation, 35 | fsv.id, 36 | fsv.mv_fs_value_uuid 37 | ); 38 | }) 39 | : []; 40 | 41 | featureStateModel.multivariateFeatureStateValues = multivariateFeatureStateValues; 42 | 43 | return featureStateModel; 44 | } 45 | 46 | export function buildFeatureSegment(featureSegmentJSON: any): FeatureSegment { 47 | return new FeatureSegment(featureSegmentJSON.priority); 48 | } 49 | -------------------------------------------------------------------------------- /flagsmith-engine/identities/models.ts: -------------------------------------------------------------------------------- 1 | import { IdentityFeaturesList } from '../utils/collections.js'; 2 | import { TraitModel } from './traits/models.js'; 3 | 4 | import { randomUUID as uuidv4 } from 'node:crypto'; 5 | 6 | export class IdentityModel { 7 | identifier: string; 8 | environmentApiKey: string; 9 | createdDate?: number; 10 | identityFeatures: IdentityFeaturesList; 11 | identityTraits: TraitModel[]; 12 | identityUuid: string; 13 | djangoID: number | undefined; 14 | 15 | constructor( 16 | created_date: string, 17 | identityTraits: TraitModel[], 18 | identityFeatures: IdentityFeaturesList, 19 | environmentApiKey: string, 20 | identifier: string, 21 | identityUuid?: string, 22 | djangoID?: number, 23 | ) { 24 | this.identityUuid = identityUuid || uuidv4(); 25 | this.createdDate = Date.parse(created_date) || Date.now(); 26 | this.identityTraits = identityTraits; 27 | this.identityFeatures = new IdentityFeaturesList(...identityFeatures); 28 | this.environmentApiKey = environmentApiKey; 29 | this.identifier = identifier; 30 | this.djangoID = djangoID; 31 | } 32 | 33 | get compositeKey() { 34 | return IdentityModel.generateCompositeKey(this.environmentApiKey, this.identifier); 35 | } 36 | 37 | static generateCompositeKey(env_key: string, identifier: string) { 38 | return `${env_key}_${identifier}`; 39 | } 40 | 41 | updateTraits(traits: TraitModel[]) { 42 | const existingTraits: Map = new Map(); 43 | for (const trait of this.identityTraits) { 44 | existingTraits.set(trait.traitKey, trait); 45 | } 46 | 47 | for (const trait of traits) { 48 | if (!!trait.traitValue) { 49 | existingTraits.set(trait.traitKey, trait); 50 | } else { 51 | existingTraits.delete(trait.traitKey); 52 | } 53 | } 54 | 55 | this.identityTraits = []; 56 | 57 | for (const [k, v] of existingTraits.entries()) { 58 | this.identityTraits.push(v); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /flagsmith-engine/identities/traits/models.ts: -------------------------------------------------------------------------------- 1 | export class TraitModel { 2 | traitKey: string; 3 | traitValue: any; 4 | constructor(key: string, value: any) { 5 | this.traitKey = key; 6 | this.traitValue = value; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /flagsmith-engine/identities/util.ts: -------------------------------------------------------------------------------- 1 | import { buildFeatureStateModel } from '../features/util.js'; 2 | import { IdentityFeaturesList } from '../utils/collections.js'; 3 | import { IdentityModel } from './models.js'; 4 | import { TraitModel } from './traits/models.js'; 5 | 6 | export function buildTraitModel(traitJSON: any): TraitModel { 7 | return new TraitModel(traitJSON.trait_key, traitJSON.trait_value); 8 | } 9 | 10 | export function buildIdentityModel(identityJSON: any): IdentityModel { 11 | const featureList = identityJSON.identity_features 12 | ? new IdentityFeaturesList( 13 | ...identityJSON.identity_features.map((f: any) => buildFeatureStateModel(f)) 14 | ) 15 | : []; 16 | 17 | const model = new IdentityModel( 18 | identityJSON.created_date, 19 | identityJSON.identity_traits 20 | ? identityJSON.identity_traits.map((trait: any) => buildTraitModel(trait)) 21 | : [], 22 | featureList, 23 | identityJSON.environment_api_key, 24 | identityJSON.identifier, 25 | identityJSON.identity_uuid 26 | ); 27 | 28 | model.djangoID = identityJSON.django_id; 29 | return model; 30 | } 31 | -------------------------------------------------------------------------------- /flagsmith-engine/index.ts: -------------------------------------------------------------------------------- 1 | import { EnvironmentModel } from './environments/models.js'; 2 | import { FeatureStateModel } from './features/models.js'; 3 | import { IdentityModel } from './identities/models.js'; 4 | import { TraitModel } from './identities/traits/models.js'; 5 | import { getIdentitySegments } from './segments/evaluators.js'; 6 | import { SegmentModel } from './segments/models.js'; 7 | import { FeatureStateNotFound } from './utils/errors.js'; 8 | 9 | export { EnvironmentModel } from './environments/models.js'; 10 | export { FeatureStateModel } from './features/models.js'; 11 | export { IdentityModel } from './identities/models.js'; 12 | export { TraitModel } from './identities/traits/models.js'; 13 | export { SegmentModel } from './segments/models.js'; 14 | export { OrganisationModel } from './organisations/models.js'; 15 | 16 | function getIdentityFeatureStatesDict( 17 | environment: EnvironmentModel, 18 | identity: IdentityModel, 19 | overrideTraits?: TraitModel[] 20 | ) { 21 | // Get feature states from the environment 22 | const featureStates: { [key: number]: FeatureStateModel } = {}; 23 | for (const fs of environment.featureStates) { 24 | featureStates[fs.feature.id] = fs; 25 | } 26 | 27 | // Override with any feature states defined by matching segments 28 | const identitySegments: SegmentModel[] = getIdentitySegments( 29 | environment, 30 | identity, 31 | overrideTraits 32 | ); 33 | for (const matchingSegment of identitySegments) { 34 | for (const featureState of matchingSegment.featureStates) { 35 | if (featureStates[featureState.feature.id]) { 36 | if (featureStates[featureState.feature.id].isHigherSegmentPriority(featureState)) { 37 | continue; 38 | } 39 | } 40 | featureStates[featureState.feature.id] = featureState; 41 | } 42 | } 43 | 44 | // Override with any feature states defined directly the identity 45 | for (const fs of identity.identityFeatures) { 46 | if (featureStates[fs.feature.id]) { 47 | featureStates[fs.feature.id] = fs; 48 | } 49 | } 50 | return featureStates; 51 | } 52 | 53 | export function getIdentityFeatureState( 54 | environment: EnvironmentModel, 55 | identity: IdentityModel, 56 | featureName: string, 57 | overrideTraits?: TraitModel[] 58 | ): FeatureStateModel { 59 | const featureStates = getIdentityFeatureStatesDict(environment, identity, overrideTraits); 60 | 61 | const matchingFeature = Object.values(featureStates).filter( 62 | f => f.feature.name === featureName 63 | ); 64 | 65 | if (matchingFeature.length === 0) { 66 | throw new FeatureStateNotFound('Feature State Not Found'); 67 | } 68 | 69 | return matchingFeature[0]; 70 | } 71 | 72 | export function getIdentityFeatureStates( 73 | environment: EnvironmentModel, 74 | identity: IdentityModel, 75 | overrideTraits?: TraitModel[] 76 | ): FeatureStateModel[] { 77 | const featureStates = Object.values( 78 | getIdentityFeatureStatesDict(environment, identity, overrideTraits) 79 | ); 80 | 81 | if (environment.project.hideDisabledFlags) { 82 | return featureStates.filter(fs => !!fs.enabled); 83 | } 84 | return featureStates; 85 | } 86 | 87 | export function getEnvironmentFeatureState(environment: EnvironmentModel, featureName: string) { 88 | const featuresStates = environment.featureStates.filter(f => f.feature.name === featureName); 89 | 90 | if (featuresStates.length === 0) { 91 | throw new FeatureStateNotFound('Feature State Not Found'); 92 | } 93 | 94 | return featuresStates[0]; 95 | } 96 | 97 | export function getEnvironmentFeatureStates(environment: EnvironmentModel): FeatureStateModel[] { 98 | if (environment.project.hideDisabledFlags) { 99 | return environment.featureStates.filter(fs => !!fs.enabled); 100 | } 101 | return environment.featureStates; 102 | } 103 | -------------------------------------------------------------------------------- /flagsmith-engine/organisations/models.ts: -------------------------------------------------------------------------------- 1 | export class OrganisationModel { 2 | id: number; 3 | name: string; 4 | featureAnalytics: boolean; 5 | stopServingFlags: boolean; 6 | persistTraitData: boolean; 7 | 8 | constructor( 9 | id: number, 10 | name: string, 11 | featureAnalytics: boolean, 12 | stopServingFlags: boolean, 13 | persistTraitData: boolean 14 | ) { 15 | this.id = id; 16 | this.name = name; 17 | this.featureAnalytics = featureAnalytics; 18 | this.stopServingFlags = stopServingFlags; 19 | this.persistTraitData = persistTraitData; 20 | } 21 | 22 | get uniqueSlug() { 23 | return this.id.toString() + '-' + this.name; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /flagsmith-engine/organisations/util.ts: -------------------------------------------------------------------------------- 1 | import { OrganisationModel } from './models.js'; 2 | 3 | export function buildOrganizationModel(organizationJSON: any): OrganisationModel { 4 | return new OrganisationModel( 5 | organizationJSON.id, 6 | organizationJSON.name, 7 | organizationJSON.feature_analytics, 8 | organizationJSON.stop_serving_flags, 9 | organizationJSON.persist_trait_data 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /flagsmith-engine/projects/models.ts: -------------------------------------------------------------------------------- 1 | import { OrganisationModel } from '../organisations/models.js'; 2 | import { SegmentModel } from '../segments/models.js'; 3 | 4 | export class ProjectModel { 5 | id: number; 6 | name: string; 7 | organisation: OrganisationModel; 8 | hideDisabledFlags: boolean; 9 | segments: SegmentModel[] = []; 10 | 11 | constructor( 12 | id: number, 13 | name: string, 14 | hideDisabledFlags: boolean, 15 | organization: OrganisationModel 16 | ) { 17 | this.id = id; 18 | this.name = name; 19 | this.hideDisabledFlags = hideDisabledFlags; 20 | this.organisation = organization; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /flagsmith-engine/projects/util.ts: -------------------------------------------------------------------------------- 1 | import { buildOrganizationModel } from '../organisations/util.js'; 2 | import { SegmentModel } from '../segments/models.js'; 3 | import { buildSegmentModel } from '../segments/util.js'; 4 | import { ProjectModel } from './models.js'; 5 | 6 | export function buildProjectModel(projectJSON: any): ProjectModel { 7 | const segments: SegmentModel[] = projectJSON['segments'] 8 | ? projectJSON['segments'].map((s: any) => buildSegmentModel(s)) 9 | : []; 10 | const model = new ProjectModel( 11 | projectJSON.id, 12 | projectJSON.name, 13 | projectJSON.hide_disabled_flags, 14 | buildOrganizationModel(projectJSON.organisation) 15 | ); 16 | model.segments = segments; 17 | return model; 18 | } 19 | -------------------------------------------------------------------------------- /flagsmith-engine/segments/constants.ts: -------------------------------------------------------------------------------- 1 | // Segment Rules 2 | export const ALL_RULE = 'ALL'; 3 | export const ANY_RULE = 'ANY'; 4 | export const NONE_RULE = 'NONE'; 5 | 6 | export const RULE_TYPES = [ALL_RULE, ANY_RULE, NONE_RULE]; 7 | 8 | // Segment Condition Operators 9 | export const EQUAL = 'EQUAL'; 10 | export const GREATER_THAN = 'GREATER_THAN'; 11 | export const LESS_THAN = 'LESS_THAN'; 12 | export const LESS_THAN_INCLUSIVE = 'LESS_THAN_INCLUSIVE'; 13 | export const CONTAINS = 'CONTAINS'; 14 | export const GREATER_THAN_INCLUSIVE = 'GREATER_THAN_INCLUSIVE'; 15 | export const NOT_CONTAINS = 'NOT_CONTAINS'; 16 | export const NOT_EQUAL = 'NOT_EQUAL'; 17 | export const REGEX = 'REGEX'; 18 | export const PERCENTAGE_SPLIT = 'PERCENTAGE_SPLIT'; 19 | export const IS_SET = 'IS_SET'; 20 | export const IS_NOT_SET = 'IS_NOT_SET'; 21 | export const MODULO = 'MODULO'; 22 | export const IN = 'IN'; 23 | 24 | export const CONDITION_OPERATORS = { 25 | EQUAL, 26 | GREATER_THAN, 27 | LESS_THAN, 28 | LESS_THAN_INCLUSIVE, 29 | CONTAINS, 30 | GREATER_THAN_INCLUSIVE, 31 | NOT_CONTAINS, 32 | NOT_EQUAL, 33 | REGEX, 34 | PERCENTAGE_SPLIT, 35 | IS_SET, 36 | IS_NOT_SET, 37 | MODULO, 38 | IN 39 | }; 40 | -------------------------------------------------------------------------------- /flagsmith-engine/segments/evaluators.ts: -------------------------------------------------------------------------------- 1 | import { EnvironmentModel } from '../environments/models.js'; 2 | import { IdentityModel } from '../identities/models.js'; 3 | import { TraitModel } from '../identities/traits/models.js'; 4 | import { getHashedPercentateForObjIds } from '../utils/hashing/index.js'; 5 | import { PERCENTAGE_SPLIT, IS_SET, IS_NOT_SET } from './constants.js'; 6 | import { SegmentConditionModel, SegmentModel, SegmentRuleModel } from './models.js'; 7 | 8 | export function getIdentitySegments( 9 | environment: EnvironmentModel, 10 | identity: IdentityModel, 11 | overrideTraits?: TraitModel[] 12 | ): SegmentModel[] { 13 | return environment.project.segments.filter(segment => 14 | evaluateIdentityInSegment(identity, segment, overrideTraits) 15 | ); 16 | } 17 | 18 | export function evaluateIdentityInSegment( 19 | identity: IdentityModel, 20 | segment: SegmentModel, 21 | overrideTraits?: TraitModel[] 22 | ): boolean { 23 | return ( 24 | segment.rules.length > 0 && 25 | segment.rules.filter(rule => 26 | traitsMatchSegmentRule( 27 | overrideTraits || identity.identityTraits, 28 | rule, 29 | segment.id, 30 | identity.djangoID || identity.compositeKey 31 | ) 32 | ).length === segment.rules.length 33 | ); 34 | } 35 | 36 | function traitsMatchSegmentRule( 37 | identityTraits: TraitModel[], 38 | rule: SegmentRuleModel, 39 | segmentId: number | string, 40 | identityId: number | string 41 | ): boolean { 42 | const matchesConditions = 43 | rule.conditions.length > 0 44 | ? rule.matchingFunction()( 45 | rule.conditions.map(condition => 46 | traitsMatchSegmentCondition(identityTraits, condition, segmentId, identityId) 47 | ) 48 | ) 49 | : true; 50 | return ( 51 | matchesConditions && 52 | rule.rules.filter(rule => 53 | traitsMatchSegmentRule(identityTraits, rule, segmentId, identityId) 54 | ).length === rule.rules.length 55 | ); 56 | } 57 | 58 | export function traitsMatchSegmentCondition( 59 | identityTraits: TraitModel[], 60 | condition: SegmentConditionModel, 61 | segmentId: number | string, 62 | identityId: number | string 63 | ): boolean { 64 | if (condition.operator == PERCENTAGE_SPLIT) { 65 | var hashedPercentage = getHashedPercentateForObjIds([segmentId, identityId]); 66 | return hashedPercentage <= parseFloat(String(condition.value)); 67 | } 68 | const traits = identityTraits.filter(t => t.traitKey === condition.property_); 69 | const trait = traits.length > 0 ? traits[0] : undefined; 70 | if (condition.operator === IS_SET ) { 71 | return !!trait; 72 | } else if (condition.operator === IS_NOT_SET){ 73 | return trait == undefined; 74 | } 75 | return trait ? condition.matchesTraitValue(trait.traitValue) : false; 76 | 77 | } 78 | -------------------------------------------------------------------------------- /flagsmith-engine/segments/models.ts: -------------------------------------------------------------------------------- 1 | import * as semver from 'semver'; 2 | 3 | import { FeatureStateModel } from '../features/models.js'; 4 | import { getCastingFunction as getCastingFunction } from '../utils/index.js'; 5 | import { 6 | ALL_RULE, 7 | ANY_RULE, 8 | NONE_RULE, 9 | NOT_CONTAINS, 10 | REGEX, 11 | MODULO, 12 | IN, 13 | CONDITION_OPERATORS 14 | } from './constants.js'; 15 | import { isSemver } from './util.js'; 16 | 17 | export const all = (iterable: Array) => iterable.filter(e => !!e).length === iterable.length; 18 | export const any = (iterable: Array) => iterable.filter(e => !!e).length > 0; 19 | 20 | export const matchingFunctions = { 21 | [CONDITION_OPERATORS.EQUAL]: (thisValue: any, otherValue: any) => thisValue == otherValue, 22 | [CONDITION_OPERATORS.GREATER_THAN]: (thisValue: any, otherValue: any) => otherValue > thisValue, 23 | [CONDITION_OPERATORS.GREATER_THAN_INCLUSIVE]: (thisValue: any, otherValue: any) => 24 | otherValue >= thisValue, 25 | [CONDITION_OPERATORS.LESS_THAN]: (thisValue: any, otherValue: any) => thisValue > otherValue, 26 | [CONDITION_OPERATORS.LESS_THAN_INCLUSIVE]: (thisValue: any, otherValue: any) => 27 | thisValue >= otherValue, 28 | [CONDITION_OPERATORS.NOT_EQUAL]: (thisValue: any, otherValue: any) => thisValue != otherValue, 29 | [CONDITION_OPERATORS.CONTAINS]: (thisValue: any, otherValue: any) => 30 | !!otherValue && otherValue.includes(thisValue), 31 | }; 32 | 33 | export const semverMatchingFunction = { 34 | ...matchingFunctions, 35 | [CONDITION_OPERATORS.EQUAL]: (thisValue: any, otherValue: any) => semver.eq(thisValue, otherValue), 36 | [CONDITION_OPERATORS.GREATER_THAN]: (thisValue: any, otherValue: any) => semver.gt(otherValue, thisValue), 37 | [CONDITION_OPERATORS.GREATER_THAN_INCLUSIVE]: (thisValue: any, otherValue: any) => 38 | semver.gte(otherValue, thisValue), 39 | [CONDITION_OPERATORS.LESS_THAN]: (thisValue: any, otherValue: any) => semver.gt(thisValue, otherValue), 40 | [CONDITION_OPERATORS.LESS_THAN_INCLUSIVE]: (thisValue: any, otherValue: any) => 41 | semver.gte(thisValue, otherValue), 42 | } 43 | 44 | export const getMatchingFunctions = (semver: boolean) => (semver ? semverMatchingFunction : matchingFunctions); 45 | 46 | export class SegmentConditionModel { 47 | EXCEPTION_OPERATOR_METHODS: { [key: string]: string } = { 48 | [NOT_CONTAINS]: 'evaluateNotContains', 49 | [REGEX]: 'evaluateRegex', 50 | [MODULO]: 'evaluateModulo', 51 | [IN]: 'evaluateIn' 52 | }; 53 | 54 | operator: string; 55 | value: string | null | undefined; 56 | property_: string | null | undefined; 57 | 58 | constructor(operator: string, value?: string | null | undefined, property?: string | null | undefined) { 59 | this.operator = operator; 60 | this.value = value; 61 | this.property_ = property; 62 | } 63 | 64 | matchesTraitValue(traitValue: any) { 65 | const evaluators: { [key: string]: CallableFunction } = { 66 | evaluateNotContains: (traitValue: any) => { 67 | return typeof traitValue == "string" && 68 | !!this.value && 69 | !traitValue.includes(this.value?.toString()); 70 | }, 71 | evaluateRegex: (traitValue: any) => { 72 | return !!this.value && !!traitValue?.toString().match(new RegExp(this.value)); 73 | }, 74 | evaluateModulo: (traitValue: any) => { 75 | if (isNaN(parseFloat(traitValue)) || !this.value) { 76 | return false 77 | } 78 | const parts = (this.value).split("|"); 79 | const [divisor, reminder] = [parseFloat(parts[0]), parseFloat(parts[1])]; 80 | return traitValue % divisor === reminder 81 | }, 82 | evaluateIn: (traitValue: any) => { 83 | return this.value?.split(',').includes(traitValue.toString()) 84 | }, 85 | }; 86 | 87 | // TODO: move this logic to the evaluator module 88 | if (this.EXCEPTION_OPERATOR_METHODS[this.operator]) { 89 | const evaluatorFunction = evaluators[this.EXCEPTION_OPERATOR_METHODS[this.operator]]; 90 | return evaluatorFunction(traitValue); 91 | } 92 | 93 | const defaultFunction = (x: any, y: any) => false; 94 | 95 | const matchingFunctionSet = getMatchingFunctions(isSemver(this.value)); 96 | const matchingFunction = matchingFunctionSet[this.operator] || defaultFunction; 97 | 98 | const traitType = isSemver(this.value) ? 'semver' : typeof traitValue; 99 | const castToTypeOfTraitValue = getCastingFunction(traitType); 100 | 101 | return matchingFunction(castToTypeOfTraitValue(this.value), traitValue); 102 | } 103 | } 104 | 105 | export class SegmentRuleModel { 106 | type: string; 107 | rules: SegmentRuleModel[] = []; 108 | conditions: SegmentConditionModel[] = []; 109 | 110 | constructor(type: string) { 111 | this.type = type; 112 | } 113 | 114 | static none(iterable: Array) { 115 | return iterable.filter(e => !!e).length === 0; 116 | } 117 | 118 | matchingFunction(): CallableFunction { 119 | return { 120 | [ANY_RULE]: any, 121 | [ALL_RULE]: all, 122 | [NONE_RULE]: SegmentRuleModel.none 123 | }[this.type] as CallableFunction; 124 | } 125 | } 126 | 127 | export class SegmentModel { 128 | id: number; 129 | name: string; 130 | rules: SegmentRuleModel[] = []; 131 | featureStates: FeatureStateModel[] = []; 132 | 133 | constructor(id: number, name: string) { 134 | this.id = id; 135 | this.name = name; 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /flagsmith-engine/segments/util.ts: -------------------------------------------------------------------------------- 1 | import { buildFeatureStateModel } from '../features/util.js'; 2 | import { SegmentConditionModel, SegmentModel, SegmentRuleModel } from './models.js'; 3 | 4 | export function buildSegmentConditionModel(segmentConditionJSON: any): SegmentConditionModel { 5 | return new SegmentConditionModel( 6 | segmentConditionJSON.operator, 7 | segmentConditionJSON.value, 8 | segmentConditionJSON.property_ 9 | ); 10 | } 11 | 12 | export function buildSegmentRuleModel(ruleModelJSON: any): SegmentRuleModel { 13 | const ruleModel = new SegmentRuleModel(ruleModelJSON.type); 14 | 15 | ruleModel.rules = ruleModelJSON.rules.map((r: any) => buildSegmentRuleModel(r)); 16 | ruleModel.conditions = ruleModelJSON.conditions.map((c: any) => buildSegmentConditionModel(c)); 17 | return ruleModel; 18 | } 19 | 20 | export function buildSegmentModel(segmentModelJSON: any): SegmentModel { 21 | const model = new SegmentModel(segmentModelJSON.id, segmentModelJSON.name); 22 | 23 | model.featureStates = segmentModelJSON['feature_states'].map((fs: any) => 24 | buildFeatureStateModel(fs) 25 | ); 26 | model.rules = segmentModelJSON['rules'].map((r: any) => buildSegmentRuleModel(r)); 27 | 28 | return model; 29 | } 30 | 31 | export function isSemver(value: any) { 32 | return typeof value == 'string' && value.endsWith(':semver'); 33 | } 34 | 35 | export function removeSemverSuffix(value: string) { 36 | return value.replace(':semver', ''); 37 | } 38 | -------------------------------------------------------------------------------- /flagsmith-engine/utils/collections.ts: -------------------------------------------------------------------------------- 1 | import { FeatureStateModel } from '../features/models.js'; 2 | 3 | export class IdentityFeaturesList extends Array {} 4 | -------------------------------------------------------------------------------- /flagsmith-engine/utils/errors.ts: -------------------------------------------------------------------------------- 1 | export class FeatureStateNotFound extends Error {} 2 | -------------------------------------------------------------------------------- /flagsmith-engine/utils/hashing/index.ts: -------------------------------------------------------------------------------- 1 | import {BinaryLike, createHash} from "node:crypto"; 2 | 3 | const md5 = (data: BinaryLike) => createHash('md5').update(data).digest('hex') 4 | 5 | const makeRepeated = (arr: Array, repeats: number) => 6 | Array.from({ length: repeats }, () => arr).flat(); 7 | 8 | // https://stackoverflow.com/questions/12532871/how-to-convert-a-very-large-hex-number-to-decimal-in-javascript 9 | /** 10 | * Given a list of object ids, get a floating point number between 0 and 1 based on 11 | * the hash of those ids. This should give the same value every time for any list of ids. 12 | * 13 | * @param {Array} objectIds list of object ids to calculate the has for 14 | * @param {} iterations=1 num times to include each id in the generated string to hash 15 | * @returns number number between 0 (inclusive) and 100 (exclusive) 16 | */ 17 | export function getHashedPercentateForObjIds(objectIds: Array, iterations = 1): number { 18 | let toHash = makeRepeated(objectIds, iterations).join(','); 19 | const hashedValue = md5(toHash); 20 | const hashedInt = BigInt('0x' + hashedValue); 21 | const value = (Number((hashedInt % 9999n)) / 9998.0) * 100; 22 | 23 | // we ignore this for it's nearly impossible use case to catch 24 | /* istanbul ignore next */ 25 | if (value === 100) { 26 | /* istanbul ignore next */ 27 | return getHashedPercentateForObjIds(objectIds, iterations + 1); 28 | } 29 | 30 | return value; 31 | } 32 | -------------------------------------------------------------------------------- /flagsmith-engine/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { removeSemverSuffix } from "../segments/util.js"; 2 | 3 | export function getCastingFunction(traitType: 'boolean' | 'string' | 'number' | 'semver' | any): CallableFunction { 4 | switch (traitType) { 5 | case 'boolean': 6 | return (x: any) => !['False', 'false'].includes(x); 7 | case 'number': 8 | return (x: any) => parseFloat(x); 9 | case 'semver': 10 | return (x: any) => removeSemverSuffix(x); 11 | default: 12 | return (x: any) => String(x); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | AnalyticsProcessor, 3 | AnalyticsProcessorOptions, 4 | FlagsmithAPIError, 5 | FlagsmithClientError, 6 | EnvironmentDataPollingManager, 7 | FlagsmithCache, 8 | DefaultFlag, 9 | Flags, 10 | Flagsmith, 11 | } from './sdk/index.js'; 12 | 13 | export { 14 | BaseOfflineHandler, 15 | LocalFileHandler, 16 | } from './sdk/offline_handlers.js'; 17 | 18 | export { 19 | FlagsmithConfig 20 | } from './sdk/types.js' 21 | 22 | export { 23 | EnvironmentModel, 24 | FeatureStateModel, 25 | IdentityModel, 26 | TraitModel, 27 | SegmentModel, 28 | OrganisationModel 29 | } from './flagsmith-engine/index.js'; 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flagsmith-nodejs", 3 | "version": "6.0.1", 4 | "description": "Flagsmith lets you manage features flags and remote config across web, mobile and server side applications. Deliver true Continuous Integration. Get builds out faster. Control who has access to new features.", 5 | "main": "./build/cjs/index.js", 6 | "type": "module", 7 | "engines": { 8 | "node": ">=18" 9 | }, 10 | "exports": { 11 | "import": "./build/esm/index.js", 12 | "require": "./build/cjs/index.js" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/Flagsmith/flagsmith-nodejs-client" 17 | }, 18 | "keywords": [ 19 | "nodejs", 20 | "flagsmith", 21 | "feature flags", 22 | "feature toggles", 23 | "remote configuration", 24 | "continuous deployment" 25 | ], 26 | "bugs": { 27 | "url": "https://github.com/Flagsmith/flagsmith-nodejs-client/issues" 28 | }, 29 | "homepage": "http://flagsmith.com/", 30 | "author": "Flagsmith", 31 | "contributors": [ 32 | { 33 | "name": "Tom Stuart", 34 | "email": "tom@solidstategroup.com" 35 | }, 36 | { 37 | "name": "Kyle Johnson", 38 | "email": "kyle.johnson@flagsmith.com", 39 | "url": "https://www.npmjs.com/~kyle-ssg" 40 | }, 41 | { 42 | "name": "Luke Fanning", 43 | "email": "luke@solidstategroup.com" 44 | }, 45 | { 46 | "name": "Matt Elwell", 47 | "email": "matthew.elwell@solidstategroup.com" 48 | } 49 | ], 50 | "license": "MIT", 51 | "scripts": { 52 | "lint": "prettier --write .", 53 | "test": "vitest --coverage --run", 54 | "test:watch": "vitest", 55 | "test:debug": "vitest --inspect-brk --no-file-parallelism --coverage", 56 | "prebuild": "rm -rf ./build", 57 | "build": "tsc -b tsconfig.cjs.json tsconfig.esm.json && echo '{\"type\": \"commonjs\"}'> build/cjs/package.json", 58 | "deploy": "npm i && npm run build && npm publish", 59 | "deploy:beta": "npm i && npm run build && npm publish --tag beta", 60 | "prepare": "husky install" 61 | }, 62 | "dependencies": { 63 | "pino": "^8.8.0", 64 | "semver": "^7.3.7", 65 | "undici-types": "^6.19.8" 66 | }, 67 | "devDependencies": { 68 | "@types/node": "^20.16.10", 69 | "@types/semver": "^7.3.9", 70 | "@types/uuid": "^8.3.4", 71 | "@vitest/coverage-v8": "^2.1.2", 72 | "esbuild": "^0.25.0", 73 | "husky": "^7.0.4", 74 | "prettier": "^2.2.1", 75 | "typescript": "^4.9.5", 76 | "undici": "^6.19.8", 77 | "vitest": "^2.1.2" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /sdk/analytics.ts: -------------------------------------------------------------------------------- 1 | import { pino, Logger } from 'pino'; 2 | import { Fetch } from "./types.js"; 3 | import { FlagsmithConfig } from "./types.js"; 4 | 5 | export const ANALYTICS_ENDPOINT = './analytics/flags/'; 6 | 7 | /** Duration in seconds to wait before trying to flush collected data after {@link trackFeature} is called. **/ 8 | const ANALYTICS_TIMER = 10; 9 | 10 | const DEFAULT_REQUEST_TIMEOUT_MS = 3000 11 | 12 | export interface AnalyticsProcessorOptions { 13 | /** URL of the Flagsmith analytics events API endpoint 14 | * @example https://flagsmith.example.com/api/v1/analytics 15 | */ 16 | analyticsUrl?: string; 17 | /** Client-side key of the environment that analytics will be recorded for. **/ 18 | environmentKey: string; 19 | /** Duration in milliseconds to wait for API requests to complete before timing out. Defaults to {@link DEFAULT_REQUEST_TIMEOUT_MS}. **/ 20 | requestTimeoutMs?: number; 21 | logger?: Logger; 22 | /** Custom {@link fetch} implementation to use for API requests. **/ 23 | fetch?: Fetch 24 | 25 | /** @deprecated Use {@link analyticsUrl} instead. **/ 26 | baseApiUrl?: string; 27 | } 28 | 29 | /** 30 | * Tracks how often individual features are evaluated whenever {@link trackFeature} is called. 31 | * 32 | * Analytics data is posted after {@link trackFeature} is called and at least {@link ANALYTICS_TIMER} seconds have 33 | * passed since the previous analytics API request was made (if any), or by calling {@link flush}. 34 | * 35 | * Data will stay in memory indefinitely until it can be successfully posted to the API. 36 | * @see https://docs.flagsmith.com/advanced-use/flag-analytics. 37 | */ 38 | export class AnalyticsProcessor { 39 | private analyticsUrl: string; 40 | private environmentKey: string; 41 | private lastFlushed: number; 42 | analyticsData: { [key: string]: any }; 43 | private requestTimeoutMs: number = DEFAULT_REQUEST_TIMEOUT_MS; 44 | private logger: Logger; 45 | private currentFlush: ReturnType | undefined; 46 | private customFetch: Fetch; 47 | 48 | constructor(data: AnalyticsProcessorOptions) { 49 | this.analyticsUrl = data.analyticsUrl || data.baseApiUrl + ANALYTICS_ENDPOINT; 50 | this.environmentKey = data.environmentKey; 51 | this.lastFlushed = Date.now(); 52 | this.analyticsData = {}; 53 | this.requestTimeoutMs = data.requestTimeoutMs || this.requestTimeoutMs; 54 | this.logger = data.logger || pino(); 55 | this.customFetch = data.fetch ?? fetch; 56 | } 57 | /** 58 | * Try to flush pending collected data to the Flagsmith analytics API. 59 | */ 60 | async flush() { 61 | if (this.currentFlush || !Object.keys(this.analyticsData).length) { 62 | return; 63 | } 64 | 65 | try { 66 | this.currentFlush = this.customFetch(this.analyticsUrl, { 67 | method: 'POST', 68 | body: JSON.stringify(this.analyticsData), 69 | signal: AbortSignal.timeout(this.requestTimeoutMs), 70 | headers: { 71 | 'Content-Type': 'application/json', 72 | 'X-Environment-Key': this.environmentKey 73 | } 74 | }); 75 | await this.currentFlush; 76 | } catch (error) { 77 | // We don't want failing to write analytics to cause any exceptions in the main 78 | // thread so we just swallow them here. 79 | this.logger.warn('Failed to post analytics to Flagsmith API. Not clearing data, will retry.') 80 | return; 81 | } finally { 82 | this.currentFlush = undefined; 83 | } 84 | 85 | this.analyticsData = {}; 86 | this.lastFlushed = Date.now(); 87 | } 88 | 89 | /** 90 | * Track a single evaluation event for a feature. 91 | * 92 | * @see FlagsmithConfig.enableAnalytics 93 | */ 94 | trackFeature(featureName: string) { 95 | this.analyticsData[featureName] = (this.analyticsData[featureName] || 0) + 1; 96 | if (Date.now() - this.lastFlushed > ANALYTICS_TIMER * 1000) { 97 | this.flush(); 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /sdk/errors.ts: -------------------------------------------------------------------------------- 1 | export class FlagsmithClientError extends Error {} 2 | export class FlagsmithAPIError extends Error {} 3 | -------------------------------------------------------------------------------- /sdk/index.ts: -------------------------------------------------------------------------------- 1 | import { Dispatcher } from 'undici-types'; 2 | import { getEnvironmentFeatureStates, getIdentityFeatureStates } from '../flagsmith-engine/index.js'; 3 | import { EnvironmentModel } from '../flagsmith-engine/index.js'; 4 | import { buildEnvironmentModel } from '../flagsmith-engine/environments/util.js'; 5 | import { IdentityModel } from '../flagsmith-engine/index.js'; 6 | import { TraitModel } from '../flagsmith-engine/index.js'; 7 | 8 | import {ANALYTICS_ENDPOINT, AnalyticsProcessor} from './analytics.js'; 9 | import { BaseOfflineHandler } from './offline_handlers.js'; 10 | import { FlagsmithAPIError } from './errors.js'; 11 | 12 | import { DefaultFlag, Flags } from './models.js'; 13 | import { EnvironmentDataPollingManager } from './polling_manager.js'; 14 | import { Deferred, generateIdentitiesData, retryFetch } from './utils.js'; 15 | import { SegmentModel } from '../flagsmith-engine/index.js'; 16 | import { getIdentitySegments } from '../flagsmith-engine/segments/evaluators.js'; 17 | import { Fetch, FlagsmithCache, FlagsmithConfig, FlagsmithTraitValue, ITraitConfig } from './types.js'; 18 | import { pino, Logger } from 'pino'; 19 | 20 | export { AnalyticsProcessor, AnalyticsProcessorOptions } from './analytics.js'; 21 | export { FlagsmithAPIError, FlagsmithClientError } from './errors.js'; 22 | 23 | export { DefaultFlag, Flags } from './models.js'; 24 | export { EnvironmentDataPollingManager } from './polling_manager.js'; 25 | export { FlagsmithCache, FlagsmithConfig } from './types.js'; 26 | 27 | const DEFAULT_API_URL = 'https://edge.api.flagsmith.com/api/v1/'; 28 | const DEFAULT_REQUEST_TIMEOUT_SECONDS = 10; 29 | 30 | /** 31 | * A client for evaluating Flagsmith feature flags. 32 | * 33 | * Flags are evaluated remotely by the Flagsmith API over HTTP by default. 34 | * To evaluate flags locally, create the client using {@link FlagsmithConfig.enableLocalEvaluation} and a server-side SDK key. 35 | * 36 | * @example 37 | * import { Flagsmith, Flags, DefaultFlag } from 'flagsmith-nodejs' 38 | * 39 | * const flagsmith = new Flagsmith({ 40 | * environmentKey: 'your_sdk_key', 41 | * defaultFlagHandler: (flagKey: string) => { new DefaultFlag(...) }, 42 | * }); 43 | * 44 | * // Fetch the current environment flags 45 | * const environmentFlags: Flags = flagsmith.getEnvironmentFlags() 46 | * const isFooEnabled: boolean = environmentFlags.isFeatureEnabled('foo') 47 | * 48 | * // Evaluate flags for any identity 49 | * const identityFlags: Flags = flagsmith.getIdentityFlags('my_user_123', {'vip': true}) 50 | * const bannerVariation: string = identityFlags.getFeatureValue('banner_flag') 51 | * 52 | * @see FlagsmithConfig 53 | */ 54 | export class Flagsmith { 55 | environmentKey?: string = undefined; 56 | apiUrl?: string = undefined; 57 | analyticsUrl?: string = undefined; 58 | customHeaders?: { [key: string]: any }; 59 | agent?: Dispatcher; 60 | requestTimeoutMs?: number; 61 | enableLocalEvaluation?: boolean = false; 62 | environmentRefreshIntervalSeconds: number = 60; 63 | retries?: number; 64 | enableAnalytics: boolean = false; 65 | defaultFlagHandler?: (featureName: string) => DefaultFlag; 66 | 67 | environmentFlagsUrl?: string; 68 | identitiesUrl?: string; 69 | environmentUrl?: string; 70 | 71 | environmentDataPollingManager?: EnvironmentDataPollingManager; 72 | private environment?: EnvironmentModel; 73 | offlineMode: boolean = false; 74 | offlineHandler?: BaseOfflineHandler = undefined; 75 | 76 | identitiesWithOverridesByIdentifier?: Map; 77 | 78 | private cache?: FlagsmithCache; 79 | private onEnvironmentChange: (error: Error | null, result?: EnvironmentModel) => void; 80 | private analyticsProcessor?: AnalyticsProcessor; 81 | private logger: Logger; 82 | private customFetch: Fetch; 83 | private readonly requestRetryDelayMilliseconds: number; 84 | 85 | /** 86 | * Creates a new {@link Flagsmith} client. 87 | * 88 | * If using local evaluation, the environment will be fetched lazily when needed by any method. Polling the 89 | * environment for updates will start after {@link environmentRefreshIntervalSeconds} once the client is created. 90 | * @param data The {@link FlagsmithConfig} options for this client. 91 | */ 92 | constructor(data: FlagsmithConfig) { 93 | this.agent = data.agent; 94 | this.customFetch = data.fetch ?? fetch; 95 | this.environmentKey = data.environmentKey; 96 | this.apiUrl = data.apiUrl || DEFAULT_API_URL; 97 | this.customHeaders = data.customHeaders; 98 | this.requestTimeoutMs = 99 | 1000 * (data.requestTimeoutSeconds ?? DEFAULT_REQUEST_TIMEOUT_SECONDS); 100 | this.requestRetryDelayMilliseconds = data.requestRetryDelayMilliseconds ?? 1000; 101 | this.enableLocalEvaluation = data.enableLocalEvaluation; 102 | this.environmentRefreshIntervalSeconds = 103 | data.environmentRefreshIntervalSeconds || this.environmentRefreshIntervalSeconds; 104 | this.retries = data.retries; 105 | this.enableAnalytics = data.enableAnalytics || false; 106 | this.defaultFlagHandler = data.defaultFlagHandler; 107 | 108 | this.onEnvironmentChange = (error, result) => data.onEnvironmentChange?.(error, result); 109 | this.logger = data.logger || pino(); 110 | this.offlineMode = data.offlineMode || false; 111 | this.offlineHandler = data.offlineHandler; 112 | 113 | // argument validation 114 | if (this.offlineMode && !this.offlineHandler) { 115 | throw new Error('ValueError: offlineHandler must be provided to use offline mode.'); 116 | } else if (this.defaultFlagHandler && this.offlineHandler) { 117 | throw new Error('ValueError: Cannot use both defaultFlagHandler and offlineHandler.'); 118 | } 119 | 120 | if (!!data.cache) { 121 | this.cache = data.cache; 122 | } 123 | 124 | if (!this.offlineMode) { 125 | if (!this.environmentKey) { 126 | throw new Error('ValueError: environmentKey is required.'); 127 | } 128 | 129 | const apiUrl = data.apiUrl || DEFAULT_API_URL; 130 | this.apiUrl = apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`; 131 | this.analyticsUrl = this.analyticsUrl || new URL(ANALYTICS_ENDPOINT, new Request(this.apiUrl).url).href 132 | this.environmentFlagsUrl = `${this.apiUrl}flags/`; 133 | this.identitiesUrl = `${this.apiUrl}identities/`; 134 | this.environmentUrl = `${this.apiUrl}environment-document/`; 135 | 136 | if (this.enableLocalEvaluation) { 137 | if (!this.environmentKey.startsWith('ser.')) { 138 | throw new Error('Using local evaluation requires a server-side environment key'); 139 | } 140 | if (this.environmentRefreshIntervalSeconds > 0){ 141 | this.environmentDataPollingManager = new EnvironmentDataPollingManager( 142 | this, 143 | this.environmentRefreshIntervalSeconds, 144 | this.logger, 145 | ); 146 | this.environmentDataPollingManager.start(); 147 | } 148 | } 149 | 150 | if (data.enableAnalytics) { 151 | this.analyticsProcessor = new AnalyticsProcessor({ 152 | environmentKey: this.environmentKey, 153 | analyticsUrl: this.analyticsUrl, 154 | requestTimeoutMs: this.requestTimeoutMs, 155 | logger: this.logger, 156 | }) 157 | } 158 | } 159 | } 160 | /** 161 | * Get all the default for flags for the current environment. 162 | * 163 | * @returns Flags object holding all the flags for the current environment. 164 | */ 165 | async getEnvironmentFlags(): Promise { 166 | const cachedItem = !!this.cache && (await this.cache.get(`flags`)); 167 | if (!!cachedItem) { 168 | return cachedItem; 169 | } 170 | try { 171 | if (this.enableLocalEvaluation || this.offlineMode) { 172 | return await this.getEnvironmentFlagsFromDocument(); 173 | } 174 | return await this.getEnvironmentFlagsFromApi(); 175 | } catch (error) { 176 | if (!this.defaultFlagHandler) { 177 | throw new Error('getEnvironmentFlags failed and no default flag handler was provided', { cause: error }); 178 | } 179 | this.logger.error(error, 'getEnvironmentFlags failed'); 180 | return new Flags({ 181 | flags: {}, 182 | defaultFlagHandler: this.defaultFlagHandler 183 | }); 184 | } 185 | } 186 | 187 | /** 188 | * Get all the flags for the current environment for a given identity. Will also 189 | upsert all traits to the Flagsmith API for future evaluations. Providing a 190 | trait with a value of None will remove the trait from the identity if it exists. 191 | * 192 | * @param {string} identifier a unique identifier for the identity in the current 193 | environment, e.g. email address, username, uuid 194 | * @param {{[key:string]:any | ITraitConfig}} traits? a dictionary of traits to add / update on the identity in 195 | Flagsmith, e.g. {"num_orders": 10} or {age: {value: 30, transient: true}} 196 | * @returns Flags object holding all the flags for the given identity. 197 | */ 198 | async getIdentityFlags( 199 | identifier: string, 200 | traits?: { [key: string]: FlagsmithTraitValue | ITraitConfig }, 201 | transient: boolean = false 202 | ): Promise { 203 | if (!identifier) { 204 | throw new Error('`identifier` argument is missing or invalid.'); 205 | } 206 | 207 | const cachedItem = !!this.cache && (await this.cache.get(`flags-${identifier}`)); 208 | if (!!cachedItem) { 209 | return cachedItem; 210 | } 211 | traits = traits || {}; 212 | try { 213 | if (this.enableLocalEvaluation || this.offlineMode) { 214 | return await this.getIdentityFlagsFromDocument(identifier, traits || {}); 215 | } 216 | return await this.getIdentityFlagsFromApi(identifier, traits, transient); 217 | } catch (error) { 218 | if (!this.defaultFlagHandler) { 219 | throw new Error('getIdentityFlags failed and no default flag handler was provided', { cause: error }) 220 | } 221 | this.logger.error(error, 'getIdentityFlags failed'); 222 | return new Flags({ 223 | flags: {}, 224 | defaultFlagHandler: this.defaultFlagHandler 225 | }); 226 | } 227 | } 228 | 229 | /** 230 | * Get the segments for the current environment for a given identity. Will also 231 | upsert all traits to the Flagsmith API for future evaluations. Providing a 232 | trait with a value of None will remove the trait from the identity if it exists. 233 | * 234 | * @param {string} identifier a unique identifier for the identity in the current 235 | environment, e.g. email address, username, uuid 236 | * @param {{[key:string]:any}} traits? a dictionary of traits to add / update on the identity in 237 | Flagsmith, e.g. {"num_orders": 10} 238 | * @returns Segments that the given identity belongs to. 239 | */ 240 | async getIdentitySegments( 241 | identifier: string, 242 | traits?: { [key: string]: any } 243 | ): Promise { 244 | if (!identifier) { 245 | throw new Error('`identifier` argument is missing or invalid.'); 246 | } 247 | if (!this.enableLocalEvaluation) { 248 | this.logger.error('This function is only permitted with local evaluation.'); 249 | return Promise.resolve([]); 250 | } 251 | 252 | traits = traits || {}; 253 | const environment = await this.getEnvironment(); 254 | const identityModel = this.getIdentityModel( 255 | environment, 256 | identifier, 257 | Object.keys(traits || {}).map(key => ({ 258 | key, 259 | value: traits?.[key] 260 | })) 261 | ); 262 | 263 | return getIdentitySegments(environment, identityModel); 264 | } 265 | 266 | private async fetchEnvironment(): Promise { 267 | const deferred = new Deferred(); 268 | this.environmentPromise = deferred.promise; 269 | try { 270 | const environment = await this.getEnvironmentFromApi(); 271 | this.environment = environment; 272 | if (environment.identityOverrides?.length) { 273 | this.identitiesWithOverridesByIdentifier = new Map( 274 | environment.identityOverrides.map(identity => [identity.identifier, identity]) 275 | ); 276 | } 277 | deferred.resolve(environment); 278 | return deferred.promise; 279 | } catch (error) { 280 | deferred.reject(error); 281 | return deferred.promise; 282 | } finally { 283 | this.environmentPromise = undefined; 284 | } 285 | } 286 | 287 | /** 288 | * Fetch the latest environment state from the Flagsmith API to use for local flag evaluation. 289 | * 290 | * If the environment is currently being fetched, calling this method will not cause additional fetches. 291 | */ 292 | async updateEnvironment(): Promise { 293 | try { 294 | if (this.environmentPromise) { 295 | await this.environmentPromise 296 | return 297 | } 298 | const environment = await this.fetchEnvironment(); 299 | this.onEnvironmentChange(null, environment); 300 | } catch (e) { 301 | this.logger.error(e, 'updateEnvironment failed'); 302 | this.onEnvironmentChange(e as Error); 303 | } 304 | } 305 | 306 | async close() { 307 | this.environmentDataPollingManager?.stop(); 308 | } 309 | 310 | private async getJSONResponse( 311 | url: string, 312 | method: string, 313 | body?: { [key: string]: any } 314 | ): Promise { 315 | const headers: { [key: string]: any } = { 'Content-Type': 'application/json' }; 316 | if (this.environmentKey) { 317 | headers['X-Environment-Key'] = this.environmentKey as string; 318 | } 319 | 320 | if (this.customHeaders) { 321 | for (const [k, v] of Object.entries(this.customHeaders)) { 322 | headers[k] = v; 323 | } 324 | } 325 | 326 | const data = await retryFetch( 327 | url, 328 | { 329 | dispatcher: this.agent, 330 | method: method, 331 | body: JSON.stringify(body), 332 | headers: headers 333 | }, 334 | this.retries, 335 | this.requestTimeoutMs, 336 | this.requestRetryDelayMilliseconds, 337 | this.customFetch, 338 | ); 339 | 340 | if (data.status !== 200) { 341 | throw new FlagsmithAPIError( 342 | `Invalid request made to Flagsmith API. Response status code: ${data.status}` 343 | ); 344 | } 345 | 346 | return data.json(); 347 | } 348 | 349 | /** 350 | * This promise ensures that the environment is retrieved before attempting to locally evaluate. 351 | */ 352 | private environmentPromise?: Promise; 353 | 354 | /** 355 | * Returns the current environment, fetching it from the API if needed. 356 | * 357 | * Calling this method concurrently while the environment is being fetched will not cause additional requests. 358 | */ 359 | async getEnvironment(): Promise { 360 | if (this.offlineHandler) { 361 | return this.offlineHandler.getEnvironment(); 362 | } 363 | if (this.environment) { 364 | return this.environment; 365 | } 366 | if (!this.environmentPromise) { 367 | this.environmentPromise = this.fetchEnvironment(); 368 | } 369 | return this.environmentPromise; 370 | } 371 | 372 | private async getEnvironmentFromApi() { 373 | if (!this.environmentUrl) { 374 | throw new Error('`apiUrl` argument is missing or invalid.'); 375 | } 376 | const environment_data = await this.getJSONResponse(this.environmentUrl, 'GET'); 377 | return buildEnvironmentModel(environment_data); 378 | } 379 | 380 | private async getEnvironmentFlagsFromDocument(): Promise { 381 | const environment = await this.getEnvironment(); 382 | const flags = Flags.fromFeatureStateModels({ 383 | featureStates: getEnvironmentFeatureStates(environment), 384 | analyticsProcessor: this.analyticsProcessor, 385 | defaultFlagHandler: this.defaultFlagHandler 386 | }); 387 | if (!!this.cache) { 388 | await this.cache.set('flags', flags); 389 | } 390 | return flags; 391 | } 392 | 393 | private async getIdentityFlagsFromDocument( 394 | identifier: string, 395 | traits: { [key: string]: any } 396 | ): Promise { 397 | const environment = await this.getEnvironment(); 398 | const identityModel = this.getIdentityModel( 399 | environment, 400 | identifier, 401 | Object.keys(traits).map(key => ({ 402 | key, 403 | value: traits[key] 404 | })) 405 | ); 406 | 407 | const featureStates = getIdentityFeatureStates(environment, identityModel); 408 | 409 | const flags = Flags.fromFeatureStateModels({ 410 | featureStates: featureStates, 411 | analyticsProcessor: this.analyticsProcessor, 412 | defaultFlagHandler: this.defaultFlagHandler, 413 | identityID: identityModel.djangoID || identityModel.compositeKey 414 | }); 415 | 416 | if (!!this.cache) { 417 | await this.cache.set(`flags-${identifier}`, flags); 418 | } 419 | 420 | return flags; 421 | } 422 | 423 | private async getEnvironmentFlagsFromApi() { 424 | if (!this.environmentFlagsUrl) { 425 | throw new Error('`apiUrl` argument is missing or invalid.'); 426 | } 427 | const apiFlags = await this.getJSONResponse(this.environmentFlagsUrl, 'GET'); 428 | const flags = Flags.fromAPIFlags({ 429 | apiFlags: apiFlags, 430 | analyticsProcessor: this.analyticsProcessor, 431 | defaultFlagHandler: this.defaultFlagHandler 432 | }); 433 | if (!!this.cache) { 434 | await this.cache.set('flags', flags); 435 | } 436 | return flags; 437 | } 438 | 439 | private async getIdentityFlagsFromApi( 440 | identifier: string, 441 | traits: { [key: string]: FlagsmithTraitValue | ITraitConfig }, 442 | transient: boolean = false 443 | ) { 444 | if (!this.identitiesUrl) { 445 | throw new Error('`apiUrl` argument is missing or invalid.'); 446 | } 447 | const data = generateIdentitiesData(identifier, traits, transient); 448 | const jsonResponse = await this.getJSONResponse(this.identitiesUrl, 'POST', data); 449 | const flags = Flags.fromAPIFlags({ 450 | apiFlags: jsonResponse['flags'], 451 | analyticsProcessor: this.analyticsProcessor, 452 | defaultFlagHandler: this.defaultFlagHandler 453 | }); 454 | if (!!this.cache) { 455 | await this.cache.set(`flags-${identifier}`, flags); 456 | } 457 | return flags; 458 | } 459 | 460 | private getIdentityModel( 461 | environment: EnvironmentModel, 462 | identifier: string, 463 | traits: { key: string; value: any }[] 464 | ) { 465 | const traitModels = traits.map(trait => new TraitModel(trait.key, trait.value)); 466 | let identityWithOverrides = 467 | this.identitiesWithOverridesByIdentifier?.get(identifier); 468 | if (identityWithOverrides) { 469 | identityWithOverrides.updateTraits(traitModels); 470 | return identityWithOverrides; 471 | } 472 | return new IdentityModel('0', traitModels, [], environment.apiKey, identifier); 473 | } 474 | } 475 | 476 | export default Flagsmith; 477 | -------------------------------------------------------------------------------- /sdk/models.ts: -------------------------------------------------------------------------------- 1 | import { FeatureStateModel } from '../flagsmith-engine/features/models.js'; 2 | import { AnalyticsProcessor } from './analytics.js'; 3 | 4 | type FlagValue = string | number | boolean | undefined; 5 | 6 | export class BaseFlag { 7 | enabled: boolean; 8 | value: FlagValue; 9 | isDefault: boolean; 10 | 11 | constructor(value: FlagValue, enabled: boolean, isDefault: boolean) { 12 | this.value = value; 13 | this.enabled = enabled; 14 | this.isDefault = isDefault; 15 | } 16 | } 17 | 18 | export class DefaultFlag extends BaseFlag { 19 | constructor(value: FlagValue, enabled: boolean) { 20 | super(value, enabled, true); 21 | } 22 | } 23 | 24 | export class Flag extends BaseFlag { 25 | featureId: number; 26 | featureName: string; 27 | 28 | constructor(params: { 29 | value: FlagValue; 30 | enabled: boolean; 31 | isDefault?: boolean; 32 | featureId: number; 33 | featureName: string; 34 | }) { 35 | super(params.value, params.enabled, !!params.isDefault); 36 | this.featureId = params.featureId; 37 | this.featureName = params.featureName; 38 | } 39 | 40 | static fromFeatureStateModel( 41 | fsm: FeatureStateModel, 42 | identityId: number | string | undefined 43 | ): Flag { 44 | return new Flag({ 45 | value: fsm.getValue(identityId), 46 | enabled: fsm.enabled, 47 | featureId: fsm.feature.id, 48 | featureName: fsm.feature.name 49 | }); 50 | } 51 | 52 | static fromAPIFlag(flagData: any): Flag { 53 | return new Flag({ 54 | enabled: flagData['enabled'], 55 | value: flagData['feature_state_value'] ?? flagData['value'], 56 | featureId: flagData['feature']['id'], 57 | featureName: flagData['feature']['name'] 58 | }); 59 | } 60 | } 61 | 62 | export class Flags { 63 | flags: { [key: string]: Flag } = {}; 64 | defaultFlagHandler?: (featureName: string) => DefaultFlag; 65 | analyticsProcessor?: AnalyticsProcessor; 66 | 67 | constructor(data: { 68 | flags: { [key: string]: Flag }; 69 | defaultFlagHandler?: (v: string) => DefaultFlag; 70 | analyticsProcessor?: AnalyticsProcessor; 71 | }) { 72 | this.flags = data.flags; 73 | this.defaultFlagHandler = data.defaultFlagHandler; 74 | this.analyticsProcessor = data.analyticsProcessor; 75 | } 76 | 77 | static fromFeatureStateModels(data: { 78 | featureStates: FeatureStateModel[]; 79 | analyticsProcessor?: AnalyticsProcessor; 80 | defaultFlagHandler?: (f: string) => DefaultFlag; 81 | identityID?: string | number; 82 | }): Flags { 83 | const flags: { [key: string]: Flag } = {}; 84 | for (const fs of data.featureStates) { 85 | flags[fs.feature.name] = Flag.fromFeatureStateModel(fs, data.identityID); 86 | } 87 | return new Flags({ 88 | flags: flags, 89 | defaultFlagHandler: data.defaultFlagHandler, 90 | analyticsProcessor: data.analyticsProcessor 91 | }); 92 | } 93 | 94 | static fromAPIFlags(data: { 95 | apiFlags: { [key: string]: any }[]; 96 | analyticsProcessor?: AnalyticsProcessor; 97 | defaultFlagHandler?: (v: string) => DefaultFlag; 98 | }): Flags { 99 | const flags: { [key: string]: Flag } = {}; 100 | 101 | for (const flagData of data.apiFlags) { 102 | flags[flagData['feature']['name']] = Flag.fromAPIFlag(flagData); 103 | } 104 | 105 | return new Flags({ 106 | flags: flags, 107 | defaultFlagHandler: data.defaultFlagHandler, 108 | analyticsProcessor: data.analyticsProcessor 109 | }); 110 | } 111 | 112 | allFlags(): Flag[] { 113 | return Object.values(this.flags); 114 | } 115 | 116 | getFlag(featureName: string): BaseFlag { 117 | const flag = this.flags[featureName]; 118 | 119 | if (!flag) { 120 | if (this.defaultFlagHandler) { 121 | return this.defaultFlagHandler(featureName); 122 | } 123 | 124 | return { enabled: false, isDefault: true, value: undefined }; 125 | } 126 | 127 | if (this.analyticsProcessor && flag.featureId) { 128 | this.analyticsProcessor.trackFeature(flag.featureName); 129 | } 130 | 131 | return flag; 132 | } 133 | 134 | getFeatureValue(featureName: string): FlagValue { 135 | return this.getFlag(featureName).value; 136 | } 137 | 138 | isFeatureEnabled(featureName: string): boolean { 139 | return this.getFlag(featureName).enabled; 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /sdk/offline_handlers.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import { buildEnvironmentModel } from '../flagsmith-engine/environments/util.js'; 3 | import { EnvironmentModel } from '../flagsmith-engine/environments/models.js'; 4 | 5 | export class BaseOfflineHandler { 6 | getEnvironment() : EnvironmentModel { 7 | throw new Error('Not implemented'); 8 | } 9 | } 10 | 11 | export class LocalFileHandler extends BaseOfflineHandler { 12 | environment: EnvironmentModel; 13 | constructor(environment_document_path: string) { 14 | super(); 15 | const environment_document = fs.readFileSync(environment_document_path, 'utf8'); 16 | this.environment = buildEnvironmentModel(JSON.parse(environment_document)); 17 | } 18 | 19 | getEnvironment(): EnvironmentModel { 20 | return this.environment; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /sdk/polling_manager.ts: -------------------------------------------------------------------------------- 1 | import Flagsmith from './index.js'; 2 | import { Logger } from 'pino'; 3 | 4 | export class EnvironmentDataPollingManager { 5 | private interval?: NodeJS.Timeout; 6 | private main: Flagsmith; 7 | private refreshIntervalSeconds: number; 8 | private logger: Logger; 9 | 10 | constructor(main: Flagsmith, refreshIntervalSeconds: number, logger: Logger) { 11 | this.main = main; 12 | this.refreshIntervalSeconds = refreshIntervalSeconds; 13 | this.logger = logger; 14 | } 15 | 16 | start() { 17 | const updateEnvironment = () => { 18 | if (this.interval) clearInterval(this.interval); 19 | this.interval = setInterval(async () => { 20 | try { 21 | await this.main.updateEnvironment(); 22 | } catch (error) { 23 | this.logger.error(error, 'failed to poll environment'); 24 | } 25 | }, this.refreshIntervalSeconds * 1000); 26 | }; 27 | updateEnvironment(); 28 | } 29 | 30 | stop() { 31 | if (!this.interval) { 32 | return; 33 | } 34 | clearInterval(this.interval); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /sdk/types.ts: -------------------------------------------------------------------------------- 1 | import { DefaultFlag, Flags } from './models.js'; 2 | import { EnvironmentModel } from '../flagsmith-engine/index.js'; 3 | import { Dispatcher } from 'undici-types'; 4 | import { Logger } from 'pino'; 5 | import { BaseOfflineHandler } from './offline_handlers.js'; 6 | import { Flagsmith } from './index.js' 7 | 8 | export type IFlagsmithValue = T; 9 | 10 | 11 | /** 12 | * Stores and retrieves {@link Flags} from a cache. 13 | */ 14 | export interface FlagsmithCache { 15 | /** 16 | * Retrieve the cached {@link Flags} for the given environment or identity, or `undefined` if no cached value exists. 17 | * @param key An environment ID or identity identifier, which is used as the cache key. 18 | */ 19 | get(key: string): Promise; 20 | 21 | /** 22 | * Persist an environment or identity's {@link Flags} in the cache. 23 | * @param key An environment ID or identity identifier, which is used as the cache key. 24 | * @param value The {@link Flags} to be stored in the cache. 25 | */ 26 | set(key: string, value: Flags): Promise; 27 | } 28 | 29 | export type Fetch = typeof fetch 30 | 31 | /** 32 | * The configuration options for a {@link Flagsmith} client. 33 | */ 34 | export interface FlagsmithConfig { 35 | /** 36 | * The environment's client-side or server-side key. 37 | */ 38 | environmentKey?: string; 39 | /** 40 | * The Flagsmith API URL. Set this if you are not using Flagsmith's public service, i.e. https://app.flagsmith.com. 41 | * 42 | * @default https://edge.api.flagsmith.com/api/v1/ 43 | */ 44 | apiUrl?: string; 45 | /** 46 | * A custom {@link Dispatcher} to use when making HTTP requests. 47 | */ 48 | agent?: Dispatcher; 49 | /** 50 | * A custom {@link fetch} implementation to use when making HTTP requests. 51 | */ 52 | fetch?: Fetch; 53 | /** 54 | * Custom headers to use in all HTTP requests. 55 | */ 56 | customHeaders?: HeadersInit 57 | /** 58 | * The network request timeout duration, in seconds. 59 | * 60 | * @default 10 61 | */ 62 | requestTimeoutSeconds?: number; 63 | /** 64 | * The amount of time, in milliseconds, to wait before retrying failed network requests. 65 | */ 66 | requestRetryDelayMilliseconds?: number; 67 | /** 68 | * If enabled, flags are evaluated locally using the environment state cached in memory. 69 | * 70 | * The client will lazily fetch the environment from the Flagsmith API, and poll it every {@link environmentRefreshIntervalSeconds}. 71 | */ 72 | enableLocalEvaluation?: boolean; 73 | /** 74 | * The time, in seconds, to wait before refreshing the cached environment state. 75 | * @default 60 76 | */ 77 | environmentRefreshIntervalSeconds?: number; 78 | /** 79 | * How many times to retry any failed network request before giving up. 80 | * @default 3 81 | */ 82 | retries?: number; 83 | /** 84 | * If enabled, the client will keep track of any flags evaluated using {@link Flags.isFeatureEnabled}, 85 | * {@link Flags.getFeatureValue} or {@link Flags.getFlag}, and periodically flush this data to the Flagsmith API. 86 | */ 87 | enableAnalytics?: boolean; 88 | /** 89 | * Used to return fallback values for flags when evaluation fails for any reason. If not provided and flag 90 | * evaluation fails, an error will be thrown intsead. 91 | * 92 | * @param flagKey The key of the flag that failed to evaluate. 93 | * 94 | * @example 95 | * // All flags disabled and with no value by default 96 | * const defaultHandler = () => new DefaultFlag(undefined, false) 97 | * 98 | * // Enable only VIP flags by default 99 | * const vipDefaultHandler = (key: string) => new Default(undefined, key.startsWith('vip_')) 100 | */ 101 | defaultFlagHandler?: (flagKey: string) => DefaultFlag; 102 | cache?: FlagsmithCache; 103 | /** 104 | * A callback function to invoke whenever the cached environment is updated. 105 | * @param error The error that occurred when the environment state failed to update, if any. 106 | * @param result The updated environment state, if no error was thrown. 107 | */ 108 | onEnvironmentChange?: (error: Error | null, result?: EnvironmentModel) => void; 109 | logger?: Logger; 110 | /** 111 | * If enabled, the client will work offline and not make any network requests. Requires {@link offlineHandler}. 112 | */ 113 | offlineMode?: boolean; 114 | /** 115 | * If {@link offlineMode} is enabled, this handler is used to calculate the values of all flags. 116 | */ 117 | offlineHandler?: BaseOfflineHandler; 118 | } 119 | 120 | export interface ITraitConfig { 121 | value: FlagsmithTraitValue; 122 | transient?: boolean; 123 | } 124 | 125 | export declare type FlagsmithTraitValue = IFlagsmithValue; 126 | -------------------------------------------------------------------------------- /sdk/utils.ts: -------------------------------------------------------------------------------- 1 | import {Fetch, FlagsmithTraitValue, ITraitConfig} from './types.js'; 2 | import {Dispatcher} from "undici-types"; 3 | 4 | type Traits = { [key: string]: ITraitConfig | FlagsmithTraitValue }; 5 | 6 | export function isTraitConfig( 7 | traitValue: ITraitConfig | FlagsmithTraitValue 8 | ): traitValue is ITraitConfig { 9 | return !!traitValue && typeof traitValue == 'object' && traitValue.value !== undefined; 10 | } 11 | 12 | export function generateIdentitiesData(identifier: string, traits: Traits, transient: boolean) { 13 | const traitsGenerated = Object.entries(traits).map(([key, value]) => { 14 | if (isTraitConfig(value)) { 15 | return { 16 | trait_key: key, 17 | trait_value: value?.value, 18 | transient: value?.transient, 19 | }; 20 | } else { 21 | return { 22 | trait_key: key, 23 | trait_value: value, 24 | }; 25 | } 26 | }); 27 | if (transient) { 28 | return { 29 | identifier: identifier, 30 | traits: traitsGenerated, 31 | transient: true 32 | }; 33 | } 34 | return { 35 | identifier: identifier, 36 | traits: traitsGenerated 37 | }; 38 | } 39 | 40 | export const delay = (ms: number) => 41 | new Promise(resolve => setTimeout(() => resolve(undefined), ms)); 42 | 43 | export const retryFetch = ( 44 | url: string, 45 | // built-in RequestInit type doesn't have dispatcher/agent 46 | fetchOptions: RequestInit & { dispatcher?: Dispatcher }, 47 | retries: number = 3, 48 | timeoutMs: number = 10, // set an overall timeout for this function 49 | retryDelayMs: number = 1000, 50 | customFetch: Fetch, 51 | ): Promise => { 52 | const retryWrapper = async (n: number): Promise => { 53 | try { 54 | return await customFetch(url, { 55 | ...fetchOptions, 56 | signal: AbortSignal.timeout(timeoutMs) 57 | }); 58 | } catch (e) { 59 | if (n > 0) { 60 | await delay(retryDelayMs); 61 | return await retryWrapper(n - 1); 62 | } else { 63 | throw e; 64 | } 65 | } 66 | }; 67 | return retryWrapper(retries); 68 | }; 69 | 70 | /** 71 | * A deferred promise can be resolved or rejected outside its creation scope. 72 | * 73 | * @template T The type of the value that the deferred promise will resolve to. 74 | * 75 | * @example 76 | * const deferred = new Deferred() 77 | * 78 | * // Pass the promise somewhere 79 | * performAsyncOperation(deferred.promise) 80 | * 81 | * // Resolve it when ready from anywhere 82 | * deferred.resolve("Operation completed") 83 | * deferred.failed("Error") 84 | */ 85 | export class Deferred { 86 | public readonly promise: Promise; 87 | private resolvePromise!: (value: T | PromiseLike) => void; 88 | private rejectPromise!: (reason?: unknown) => void; 89 | 90 | constructor(initial?: T) { 91 | this.promise = new Promise((resolve, reject) => { 92 | this.resolvePromise = resolve; 93 | this.rejectPromise = reject; 94 | }); 95 | } 96 | 97 | public resolve(value: T | PromiseLike): void { 98 | this.resolvePromise(value); 99 | } 100 | 101 | public reject(reason?: unknown): void { 102 | this.rejectPromise(reason); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /tests/engine/e2e/engine.test.ts: -------------------------------------------------------------------------------- 1 | import { getIdentityFeatureStates } from '../../../flagsmith-engine/index.js'; 2 | import { EnvironmentModel } from '../../../flagsmith-engine/environments/models.js'; 3 | import { buildEnvironmentModel } from '../../../flagsmith-engine/environments/util.js'; 4 | import { IdentityModel } from '../../../flagsmith-engine/identities/models.js'; 5 | import { buildIdentityModel } from '../../../flagsmith-engine/identities/util.js'; 6 | import * as testData from '../engine-tests/engine-test-data/data/environment_n9fbf9h3v4fFgH3U3ngWhb.json' 7 | 8 | function extractTestCases( 9 | data: any 10 | ): { 11 | environment: EnvironmentModel; 12 | identity: IdentityModel; 13 | response: any; 14 | }[] { 15 | const environmentModel = buildEnvironmentModel(data['environment']); 16 | const test_data = data['identities_and_responses'].map((test_case: any) => { 17 | const identity = buildIdentityModel(test_case['identity']); 18 | 19 | return { 20 | environment: environmentModel, 21 | identity: identity, 22 | response: test_case['response'] 23 | }; 24 | }); 25 | return test_data; 26 | } 27 | 28 | test('Test Engine', () => { 29 | const testCases = extractTestCases(testData); 30 | for (const testCase of testCases) { 31 | const engine_response = getIdentityFeatureStates(testCase.environment, testCase.identity); 32 | const sortedEngineFlags = engine_response.sort((a, b) => 33 | a.feature.name > b.feature.name ? 1 : -1 34 | ); 35 | const sortedAPIFlags = testCase.response['flags'].sort((a: any, b: any) => 36 | a.feature.name > b.feature.name ? 1 : -1 37 | ); 38 | 39 | expect(sortedEngineFlags.length).toBe(sortedAPIFlags.length); 40 | 41 | for (let i = 0; i < sortedEngineFlags.length; i++) { 42 | expect(sortedEngineFlags[i].getValue(testCase.identity.djangoID)).toBe( 43 | sortedAPIFlags[i]['feature_state_value'] 44 | ); 45 | expect(sortedEngineFlags[i].enabled).toBe(sortedAPIFlags[i]['enabled']); 46 | } 47 | } 48 | }); 49 | -------------------------------------------------------------------------------- /tests/engine/unit/engine.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getEnvironmentFeatureState, 3 | getEnvironmentFeatureStates, 4 | getIdentityFeatureState, 5 | getIdentityFeatureStates 6 | } from '../../../flagsmith-engine/index.js'; 7 | import { CONSTANTS } from '../../../flagsmith-engine/features/constants.js'; 8 | import { FeatureModel, FeatureStateModel } from '../../../flagsmith-engine/features/models.js'; 9 | import { TraitModel } from '../../../flagsmith-engine/identities/traits/models.js'; 10 | import { 11 | environment, 12 | environmentWithSegmentOverride, 13 | feature1, 14 | getEnvironmentFeatureStateForFeature, 15 | getEnvironmentFeatureStateForFeatureByName, 16 | identity, 17 | identityInSegment, 18 | segmentConditionProperty, 19 | segmentConditionStringValue 20 | } from './utils.js'; 21 | 22 | test('test_identity_get_feature_state_without_any_override', () => { 23 | const feature_state = getIdentityFeatureState(environment(), identity(), feature1().name); 24 | 25 | expect(feature_state.feature).toStrictEqual(feature1()); 26 | }); 27 | 28 | test('test_identity_get_feature_state_without_any_override_no_fs', () => { 29 | expect(() => { 30 | getIdentityFeatureState(environment(), identity(), 'nonExistentName'); 31 | }).toThrowError('Feature State Not Found'); 32 | }); 33 | 34 | test('test_identity_get_all_feature_states_no_segments', () => { 35 | const env = environment(); 36 | const ident = identity(); 37 | const overridden_feature = new FeatureModel(3, 'overridden_feature', CONSTANTS.STANDARD); 38 | 39 | env.featureStates.push(new FeatureStateModel(overridden_feature, false, 3)); 40 | 41 | ident.identityFeatures = [new FeatureStateModel(overridden_feature, true, 4)]; 42 | 43 | const featureStates = getIdentityFeatureStates(env, ident); 44 | 45 | expect(featureStates.length).toBe(3); 46 | for (const featuresState of featureStates) { 47 | const environmentFeatureState = getEnvironmentFeatureStateForFeature( 48 | env, 49 | featuresState.feature 50 | ); 51 | const expected = 52 | environmentFeatureState?.feature == overridden_feature 53 | ? true 54 | : environmentFeatureState?.enabled; 55 | expect(featuresState.enabled).toBe(expected); 56 | } 57 | }); 58 | 59 | test('test_identity_get_all_feature_states_with_traits', () => { 60 | const trait_models = new TraitModel(segmentConditionProperty, segmentConditionStringValue); 61 | 62 | const featureStates = getIdentityFeatureStates( 63 | environmentWithSegmentOverride(), 64 | identityInSegment(), 65 | [trait_models] 66 | ); 67 | expect(featureStates[0].getValue()).toBe('segment_override'); 68 | }); 69 | 70 | test('test_identity_get_all_feature_states_with_traits_hideDisabledFlags', () => { 71 | const trait_models = new TraitModel(segmentConditionProperty, segmentConditionStringValue); 72 | 73 | const env = environmentWithSegmentOverride(); 74 | env.project.hideDisabledFlags = true; 75 | 76 | const featureStates = getIdentityFeatureStates( 77 | env, 78 | identityInSegment(), 79 | [trait_models] 80 | ); 81 | expect(featureStates.length).toBe(0); 82 | }); 83 | 84 | test('test_environment_get_all_feature_states', () => { 85 | const env = environment(); 86 | const featureStates = getEnvironmentFeatureStates(env); 87 | 88 | expect(featureStates).toBe(env.featureStates); 89 | }); 90 | 91 | test('test_environment_get_feature_states_hides_disabled_flags_if_enabled', () => { 92 | const env = environment(); 93 | 94 | env.project.hideDisabledFlags = true; 95 | 96 | const featureStates = getEnvironmentFeatureStates(env); 97 | 98 | expect(featureStates).not.toBe(env.featureStates); 99 | for (const fs of featureStates) { 100 | expect(fs.enabled).toBe(true); 101 | } 102 | }); 103 | 104 | test('test_environment_get_feature_state', () => { 105 | const env = environment(); 106 | const feature = feature1(); 107 | const featureState = getEnvironmentFeatureState(env, feature.name); 108 | 109 | expect(featureState.feature).toStrictEqual(feature); 110 | }); 111 | 112 | test('test_environment_get_feature_state_raises_feature_state_not_found', () => { 113 | expect(() => { 114 | getEnvironmentFeatureState(environment(), 'not_a_feature_name'); 115 | }).toThrowError('Feature State Not Found'); 116 | }); 117 | -------------------------------------------------------------------------------- /tests/engine/unit/environments/builder.test.ts: -------------------------------------------------------------------------------- 1 | import { EnvironmentModel } from '../../../../flagsmith-engine/environments/models'; 2 | import { 3 | buildEnvironmentAPIKeyModel, 4 | buildEnvironmentModel 5 | } from '../../../../flagsmith-engine/environments/util'; 6 | import { CONSTANTS } from '../../../../flagsmith-engine/features/constants'; 7 | import { 8 | FeatureStateModel, 9 | MultivariateFeatureStateValueModel 10 | } from '../../../../flagsmith-engine/features/models'; 11 | import { getEnvironmentFeatureStateForFeatureByName } from '../utils'; 12 | 13 | test('test_get_flags_for_environment_returns_feature_states_for_environment_dictionary', () => { 14 | const stringValue = 'foo'; 15 | const featureWithStringValueName = 'feature_with_string_value'; 16 | 17 | const environmentDict = { 18 | id: 1, 19 | api_key: 'api-key', 20 | project: { 21 | id: 1, 22 | name: 'test project', 23 | organisation: { 24 | id: 1, 25 | name: 'Test Org', 26 | stop_serving_flags: false, 27 | persist_trait_data: true, 28 | feature_analytics: true 29 | }, 30 | hide_disabled_flags: false 31 | }, 32 | feature_states: [ 33 | { 34 | id: 1, 35 | enabled: true, 36 | feature_state_value: undefined, 37 | feature: { id: 1, name: 'enabled_feature', type: CONSTANTS.STANDARD } 38 | }, 39 | { 40 | id: 2, 41 | enabled: false, 42 | feature_state_value: undefined, 43 | feature: { id: 2, name: 'disabled_feature', type: CONSTANTS.STANDARD } 44 | }, 45 | { 46 | id: 3, 47 | enabled: true, 48 | feature_state_value: stringValue, 49 | feature: { 50 | id: 3, 51 | name: featureWithStringValueName, 52 | type: CONSTANTS.STANDARD 53 | } 54 | } 55 | ] 56 | }; 57 | 58 | const environmentModel = buildEnvironmentModel(environmentDict); 59 | 60 | expect(environmentModel).toBeInstanceOf(EnvironmentModel); 61 | expect(environmentModel.featureStates.length).toBe(3); 62 | for (const fs of environmentModel.featureStates) { 63 | expect(fs).toBeInstanceOf(FeatureStateModel); 64 | } 65 | const receivedValue = getEnvironmentFeatureStateForFeatureByName( 66 | environmentModel, 67 | featureWithStringValueName 68 | )?.getValue(); 69 | expect(receivedValue).toBe(stringValue); 70 | }); 71 | 72 | test('test_build_environment_model_with_multivariate_flag', () => { 73 | const variate1Value = 'value-1'; 74 | const variate2Value = 'value-2'; 75 | 76 | const environmentJSON = { 77 | id: 1, 78 | api_key: 'api-key', 79 | project: { 80 | id: 1, 81 | name: 'test project', 82 | organisation: { 83 | id: 1, 84 | name: 'Test Org', 85 | stop_serving_flags: false, 86 | persist_trait_data: true, 87 | feature_analytics: true 88 | }, 89 | hide_disabled_flags: false 90 | }, 91 | feature_states: [ 92 | { 93 | id: 1, 94 | enabled: true, 95 | feature_state_value: undefined, 96 | feature: { 97 | id: 1, 98 | name: 'enabled_feature', 99 | type: CONSTANTS.STANDARD 100 | }, 101 | multivariate_feature_state_values: [ 102 | { 103 | id: 1, 104 | percentage_allocation: 10.0, 105 | multivariate_feature_option: { 106 | value: variate1Value 107 | } 108 | }, 109 | { 110 | id: 2, 111 | percentage_allocation: 10.0, 112 | multivariate_feature_option: { 113 | value: variate2Value, 114 | id: 2 115 | } 116 | } 117 | ] 118 | } 119 | ] 120 | }; 121 | 122 | const environmentModel = buildEnvironmentModel(environmentJSON); 123 | 124 | expect(environmentModel).toBeInstanceOf(EnvironmentModel); 125 | expect(environmentJSON.feature_states.length).toBe(1); 126 | 127 | const fs = environmentModel.featureStates[0]; 128 | 129 | for (const mvfs of fs.multivariateFeatureStateValues) { 130 | expect(mvfs).toBeInstanceOf(MultivariateFeatureStateValueModel); 131 | } 132 | }); 133 | 134 | test('test_build_environment_api_key_model', () => { 135 | const environmentKeyJSON = { 136 | key: 'ser.7duQYrsasJXqdGsdaagyfU', 137 | active: true, 138 | created_at: '2022-02-07T04:58:25.969438+00:00', 139 | client_api_key: 'RQchaCQ2mYicSCAwKoAg2E', 140 | id: 10, 141 | name: 'api key 2', 142 | expires_at: undefined 143 | }; 144 | 145 | const environmentAPIKeyModel = buildEnvironmentAPIKeyModel(environmentKeyJSON); 146 | 147 | expect(environmentAPIKeyModel.key).toBe(environmentKeyJSON['key']); 148 | }); 149 | -------------------------------------------------------------------------------- /tests/engine/unit/environments/models.test.ts: -------------------------------------------------------------------------------- 1 | import { EnvironmentAPIKeyModel } from '../../../../flagsmith-engine/environments/models'; 2 | 3 | test('test_environment_api_key_model_is_valid_is_true_for_non_expired_active_key', () => { 4 | const environmentAPIKeyModel = new EnvironmentAPIKeyModel( 5 | 1, 6 | 'ser.random_key', 7 | Date.now(), 8 | 'test_key', 9 | 'test_key' 10 | ); 11 | expect(environmentAPIKeyModel.isValid()).toBe(true); 12 | }); 13 | 14 | test('test_environment_api_key_model_is_valid_is_true_for_non_expired_active_key_with_expired_date_in_future', () => { 15 | const environmentAPIKeyModel = new EnvironmentAPIKeyModel( 16 | 1, 17 | 'ser.random_key', 18 | Date.now(), 19 | 'test_key', 20 | 'test_key', 21 | Date.now() + 1000 * 60 * 60 * 24 * 2 22 | ); 23 | expect(environmentAPIKeyModel.isValid()).toBe(true); 24 | }); 25 | 26 | test('test_environment_api_key_model_is_valid_is_false_for_expired_active_key', () => { 27 | const environmentAPIKeyModel = new EnvironmentAPIKeyModel( 28 | 1, 29 | 'ser.random_key', 30 | Date.now() - 1000 * 60 * 60 * 24 * 2, 31 | 'test_key', 32 | 'test_key', 33 | Date.now() 34 | ); 35 | expect(environmentAPIKeyModel.isValid()).toBe(false); 36 | }); 37 | 38 | test('test_environment_api_key_model_is_valid_is_false_for_non_expired_inactive_key', () => { 39 | const environmentAPIKeyModel = new EnvironmentAPIKeyModel( 40 | 1, 41 | 'ser.random_key', 42 | Date.now(), 43 | 'test_key', 44 | 'test_key' 45 | ); 46 | 47 | environmentAPIKeyModel.active = false; 48 | expect(environmentAPIKeyModel.isValid()).toBe(false); 49 | }); 50 | -------------------------------------------------------------------------------- /tests/engine/unit/features/models.test.ts: -------------------------------------------------------------------------------- 1 | import { CONSTANTS } from '../../../../flagsmith-engine/features/constants'; 2 | import { 3 | FeatureModel, 4 | FeatureStateModel, 5 | MultivariateFeatureOptionModel, 6 | MultivariateFeatureStateValueModel 7 | } from '../../../../flagsmith-engine/features/models'; 8 | import { feature1 } from '../utils'; 9 | 10 | test('test_compare_feature_model', () => { 11 | const fm1 = new FeatureModel(1, 'a', 'test'); 12 | const fm2 = new FeatureModel(1, 'a', 'test'); 13 | expect(fm1.eq(fm2)).toBe(true); 14 | }); 15 | 16 | test('test_initializing_feature_state_creates_default_feature_state_uuid', () => { 17 | const featureState = new FeatureStateModel(feature1(), true, 1); 18 | expect(featureState.featurestateUUID).toBeDefined(); 19 | }); 20 | 21 | test('test_initializing_multivariate_feature_state_value_creates_default_uuid', () => { 22 | const mvFeatureOption = new MultivariateFeatureOptionModel('value'); 23 | const mvFsValueModel = new MultivariateFeatureStateValueModel(mvFeatureOption, 10, 1); 24 | 25 | expect(mvFsValueModel.mvFsValueUuid).toBeDefined(); 26 | }); 27 | 28 | test('test_feature_state_get_value_no_mv_values', () => { 29 | const value = 'foo'; 30 | const featureState = new FeatureStateModel(feature1(), true, 1); 31 | 32 | featureState.setValue(value); 33 | 34 | expect(featureState.getValue()).toBe(value); 35 | expect(featureState.getValue(1)).toBe(value); 36 | }); 37 | 38 | test('test_feature_state_get_value_mv_values', () => { 39 | const mvFeatureControlValue = 'control'; 40 | const mvFeatureValue1 = 'foo'; 41 | const mvFeatureValue2 = 'bar'; 42 | 43 | const cases = [ 44 | [10, mvFeatureValue1], 45 | [40, mvFeatureValue2], 46 | [70, mvFeatureControlValue] 47 | ]; 48 | 49 | for (const testCase of cases) { 50 | const myFeature = new FeatureModel(1, 'mv_feature', CONSTANTS.STANDARD); 51 | 52 | const mvFeatureOption1 = new MultivariateFeatureOptionModel(mvFeatureValue1, 1); 53 | const mvFeatureOption2 = new MultivariateFeatureOptionModel(mvFeatureValue2, 2); 54 | 55 | const mvFeatureStateValue1 = new MultivariateFeatureStateValueModel( 56 | mvFeatureOption1, 57 | 30, 58 | 1 59 | ); 60 | const mvFeatureStateValue2 = new MultivariateFeatureStateValueModel( 61 | mvFeatureOption2, 62 | 30, 63 | 2 64 | ); 65 | 66 | const mvFeatureState = new FeatureStateModel(myFeature, true, 1); 67 | mvFeatureState.multivariateFeatureStateValues = [ 68 | mvFeatureStateValue1, 69 | mvFeatureStateValue2, 70 | ]; 71 | 72 | mvFeatureState.setValue(mvFeatureControlValue); 73 | 74 | expect(mvFeatureState.getValue("test")).toBe(mvFeatureValue2); 75 | } 76 | }); 77 | -------------------------------------------------------------------------------- /tests/engine/unit/identities/identities_builders.test.ts: -------------------------------------------------------------------------------- 1 | import { FeatureStateModel } from '../../../../flagsmith-engine/features/models'; 2 | import { IdentityModel } from '../../../../flagsmith-engine/identities/models'; 3 | import { buildIdentityModel } from '../../../../flagsmith-engine/identities/util'; 4 | 5 | test('test_build_identity_model_from_dictionary_no_feature_states', () => { 6 | const identity = { 7 | id: 1, 8 | identifier: 'test-identity', 9 | environment_api_key: 'api-key', 10 | created_date: '2021-08-22T06:25:23.406995Z', 11 | identity_traits: [{ trait_key: 'trait_key', trait_value: 'trait_value' }] 12 | }; 13 | 14 | const identityModel = buildIdentityModel(identity); 15 | 16 | expect(identityModel.identityFeatures?.length).toBe(0); 17 | expect(identityModel.identityTraits.length).toBe(1); 18 | }); 19 | 20 | test('test_build_identity_model_from_dictionary_uses_identity_feature_list_for_identity_features', () => { 21 | const identity_dict = { 22 | id: 1, 23 | identifier: 'test-identity', 24 | environment_api_key: 'api-key', 25 | created_date: '2021-08-22T06:25:23.406995Z', 26 | identity_features: [ 27 | { 28 | id: 1, 29 | feature: { 30 | id: 1, 31 | name: 'test_feature', 32 | type: 'STANDARD' 33 | }, 34 | enabled: true, 35 | feature_state_value: 'some-value' 36 | } 37 | ] 38 | }; 39 | 40 | const identityModel = buildIdentityModel(identity_dict); 41 | 42 | expect(identityModel.identityFeatures?.length).toBe(1); 43 | }); 44 | 45 | test('test_build_identity_model_from_dictionary_uses_identity_feature_list_for_identity_features', () => { 46 | const identity_dict = { 47 | id: 1, 48 | identifier: 'test-identity', 49 | environment_api_key: 'api-key', 50 | created_date: '2021-08-22T06:25:23.406995Z', 51 | }; 52 | 53 | const identityModel = buildIdentityModel(identity_dict); 54 | 55 | expect(identityModel.identityFeatures?.length).toBe(0); 56 | }); 57 | 58 | test('test_build_build_identity_model_from_dict_creates_identity_uuid', () => { 59 | const identity_model = buildIdentityModel({ 60 | identifier: 'test_user', 61 | environment_api_key: 'some_key' 62 | }); 63 | expect(identity_model.identityUuid).not.toBe(undefined); 64 | }); 65 | 66 | test('test_build_identity_model_from_dictionary_with_feature_states', () => { 67 | const identity_dict = { 68 | id: 1, 69 | identifier: 'test-identity', 70 | environment_api_key: 'api-key', 71 | created_date: '2021-08-22T06:25:23.406995Z', 72 | identity_features: [ 73 | { 74 | id: 1, 75 | feature: { 76 | id: 1, 77 | name: 'test_feature', 78 | type: 'STANDARD' 79 | }, 80 | enabled: true, 81 | feature_state_value: 'some-value' 82 | } 83 | ] 84 | }; 85 | 86 | const identityModel = buildIdentityModel(identity_dict); 87 | 88 | expect(identityModel).toBeInstanceOf(IdentityModel); 89 | expect(identityModel.identityFeatures?.length).toBe(1); 90 | expect(identityModel?.identityFeatures![0]).toBeInstanceOf(FeatureStateModel); 91 | }); 92 | 93 | test('test_identity_dict_created_using_model_can_convert_back_to_model', () => { 94 | const identityModel = new IdentityModel('some_key', [], [], '', ''); 95 | 96 | const identityJSON = JSON.parse(JSON.stringify(identityModel)); 97 | expect(buildIdentityModel(identityJSON)).toBeInstanceOf(IdentityModel); 98 | }); 99 | -------------------------------------------------------------------------------- /tests/engine/unit/identities/identities_models.test.ts: -------------------------------------------------------------------------------- 1 | import { FeatureStateModel } from '../../../../flagsmith-engine/features/models'; 2 | import { IdentityModel } from '../../../../flagsmith-engine/identities/models'; 3 | import { TraitModel } from '../../../../flagsmith-engine/identities/traits/models'; 4 | import { buildIdentityModel } from '../../../../flagsmith-engine/identities/util'; 5 | import { feature1, identityInSegment } from '../utils'; 6 | 7 | test('test_composite_key', () => { 8 | const identity = { 9 | id: 1, 10 | identifier: 'test-identity', 11 | environment_api_key: 'api-key', 12 | created_date: '2021-08-22T06:25:23.406995Z', 13 | identity_traits: [{ trait_key: 'trait_key', trait_value: 'trait_value' }] 14 | }; 15 | 16 | const identityModel = buildIdentityModel(identity); 17 | 18 | expect(identityModel.compositeKey).toBe('api-key_test-identity'); 19 | }); 20 | 21 | test('test_identiy_model_creates_default_identity_uuid', () => { 22 | const identity = { 23 | id: 1, 24 | identifier: 'test-identity', 25 | environment_api_key: 'api-key', 26 | created_date: '2021-08-22T06:25:23.406995Z', 27 | identity_traits: [{ trait_key: 'trait_key', trait_value: 'trait_value' }] 28 | }; 29 | 30 | const identityModel = buildIdentityModel(identity); 31 | 32 | expect(identityModel.identityUuid).toBeDefined(); 33 | }); 34 | 35 | test('test_generate_composite_key', () => { 36 | const identity = { 37 | id: 1, 38 | identifier: 'test-identity', 39 | environment_api_key: 'api-key', 40 | created_date: '2021-08-22T06:25:23.406995Z', 41 | identity_traits: [{ trait_key: 'trait_key', trait_value: 'trait_value' }] 42 | }; 43 | 44 | const identityModel = buildIdentityModel(identity); 45 | 46 | expect(IdentityModel.generateCompositeKey('api-key', 'test-identity')).toBe( 47 | 'api-key_test-identity' 48 | ); 49 | }); 50 | 51 | test('test_update_traits_remove_traits_with_none_value', () => { 52 | const ident = identityInSegment(); 53 | 54 | const trait_key = ident.identityTraits[0].traitKey; 55 | const trait_to_remove = new TraitModel(trait_key, undefined); 56 | 57 | ident.updateTraits([trait_to_remove]); 58 | 59 | expect(ident.identityTraits.length).toBe(0); 60 | }); 61 | 62 | test('test_update_identity_traits_updates_trait_value', () => { 63 | const identity = identityInSegment(); 64 | 65 | const traitKey = identity.identityTraits[0].traitKey; 66 | const traitValue = 'updated_trait_value'; 67 | const traitToUpdate = new TraitModel(traitKey, traitValue); 68 | 69 | identity.updateTraits([traitToUpdate]); 70 | 71 | expect(identity.identityTraits.length).toBe(1); 72 | expect(identity.identityTraits[0]).toBe(traitToUpdate); 73 | }); 74 | 75 | test('test_update_traits_adds_new_traits', () => { 76 | const identity = identityInSegment(); 77 | 78 | const newTrait = new TraitModel('new_key', 'foobar'); 79 | 80 | identity.updateTraits([newTrait]); 81 | 82 | expect(identity.identityTraits.length).toBe(2); 83 | expect(identity.identityTraits).toContain(newTrait); 84 | }); 85 | 86 | test('test_append_feature_state', () => { 87 | const ident = identityInSegment(); 88 | 89 | const fs1 = new FeatureStateModel(feature1(), false, 1); 90 | 91 | ident.identityFeatures.push(fs1); 92 | 93 | expect(ident.identityFeatures).toContain(fs1); 94 | }); 95 | -------------------------------------------------------------------------------- /tests/engine/unit/organization/models.test.ts: -------------------------------------------------------------------------------- 1 | import { buildOrganizationModel } from '../../../../flagsmith-engine/organisations/util'; 2 | 3 | test('Test builder', () => { 4 | const model = buildOrganizationModel({ 5 | persist_trait_data: true, 6 | name: 'Flagsmith', 7 | feature_analytics: false, 8 | stop_serving_flags: false, 9 | id: 13 10 | }); 11 | expect(model.uniqueSlug).toBe('13-Flagsmith'); 12 | }); 13 | -------------------------------------------------------------------------------- /tests/engine/unit/segments/segment_evaluators.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ALL_RULE, 3 | CONDITION_OPERATORS, 4 | PERCENTAGE_SPLIT, 5 | } from '../../../../flagsmith-engine/segments/constants.js'; 6 | import {SegmentConditionModel} from '../../../../flagsmith-engine/segments/models.js'; 7 | import {traitsMatchSegmentCondition, evaluateIdentityInSegment} from "../../../../flagsmith-engine/segments/evaluators.js"; 8 | import {TraitModel, IdentityModel} from "../../../../flagsmith-engine/index.js"; 9 | import {environment} from "../utils.js"; 10 | import { buildSegmentModel } from '../../../../flagsmith-engine/segments/util.js'; 11 | import { getHashedPercentateForObjIds } from '../../../../flagsmith-engine/utils/hashing/index.js'; 12 | 13 | 14 | // todo: work out how to implement this in a test function or before hook 15 | vi.mock('../../../../flagsmith-engine/utils/hashing', () => ({ 16 | getHashedPercentateForObjIds: vi.fn(() => 1) 17 | })); 18 | 19 | 20 | let traitExistenceTestCases: [string, string | null | undefined, string | null | undefined, TraitModel [],boolean][] = [ 21 | [CONDITION_OPERATORS.IS_SET,'foo', null,[] , false], 22 | [CONDITION_OPERATORS.IS_SET, 'foo',undefined , [new TraitModel('foo','bar')], true], 23 | [CONDITION_OPERATORS.IS_SET, 'foo',undefined , [new TraitModel('foo','bar'), new TraitModel('fooBaz','baz')], true], 24 | [CONDITION_OPERATORS.IS_NOT_SET, 'foo', undefined, [], true], 25 | [CONDITION_OPERATORS.IS_NOT_SET, 'foo', null, [new TraitModel('foo','bar')], false], 26 | [CONDITION_OPERATORS.IS_NOT_SET, 'foo', null, [new TraitModel('foo','bar'), new TraitModel('fooBaz','baz')], false] 27 | ]; 28 | 29 | test('test_traits_match_segment_condition_for_trait_existence_operators', () => { 30 | for (const testCase of traitExistenceTestCases) { 31 | const [operator, conditionProperty, conditionValue, traits, expectedResult] = testCase 32 | let segmentModel = new SegmentConditionModel(operator, conditionValue, conditionProperty) 33 | expect( 34 | traitsMatchSegmentCondition (traits, segmentModel, 'any','any') 35 | ).toBe(expectedResult); 36 | } 37 | }); 38 | 39 | 40 | test('evaluateIdentityInSegment uses django ID for hashed percentage when present', () => { 41 | var identityModel = new IdentityModel(Date.now().toString(), [], [], environment().apiKey, 'identity_1', undefined, 1); 42 | const segmentDefinition = { 43 | id: 1, 44 | name: 'percentage_split_segment', 45 | rules: [ 46 | { 47 | type: ALL_RULE, 48 | conditions: [ 49 | { 50 | operator: PERCENTAGE_SPLIT, 51 | property_: null, 52 | value: "10" 53 | } 54 | ], 55 | rules: [] 56 | } 57 | ], 58 | feature_states: [] 59 | }; 60 | const segmentModel = buildSegmentModel(segmentDefinition); 61 | 62 | var result = evaluateIdentityInSegment(identityModel, segmentModel); 63 | 64 | expect(result).toBe(true); 65 | expect(getHashedPercentateForObjIds).toHaveBeenCalledTimes(1) 66 | expect(getHashedPercentateForObjIds).toHaveBeenCalledWith([segmentModel.id, identityModel.djangoID]) 67 | }); 68 | -------------------------------------------------------------------------------- /tests/engine/unit/segments/segments_model.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ALL_RULE, 3 | ANY_RULE, 4 | CONDITION_OPERATORS, 5 | NONE_RULE 6 | } from '../../../../flagsmith-engine/segments/constants'; 7 | import { 8 | all, 9 | any, 10 | SegmentConditionModel, 11 | SegmentRuleModel 12 | } from '../../../../flagsmith-engine/segments/models'; 13 | 14 | const conditionMatchCases: [string, string | number | boolean | null, string, boolean][] = [ 15 | [CONDITION_OPERATORS.EQUAL, 'bar', 'bar', true], 16 | [CONDITION_OPERATORS.EQUAL, 'bar', 'baz', false], 17 | [CONDITION_OPERATORS.EQUAL, 1, '1', true], 18 | [CONDITION_OPERATORS.EQUAL, 1, '2', false], 19 | [CONDITION_OPERATORS.EQUAL, true, 'true', true], 20 | [CONDITION_OPERATORS.EQUAL, false, 'false', true], 21 | [CONDITION_OPERATORS.EQUAL, false, 'true', false], 22 | [CONDITION_OPERATORS.EQUAL, true, 'false', false], 23 | [CONDITION_OPERATORS.EQUAL, 1.23, '1.23', true], 24 | [CONDITION_OPERATORS.EQUAL, 1.23, '4.56', false], 25 | [CONDITION_OPERATORS.GREATER_THAN, 2, '1', true], 26 | [CONDITION_OPERATORS.GREATER_THAN, 1, '1', false], 27 | [CONDITION_OPERATORS.GREATER_THAN, 0, '1', false], 28 | [CONDITION_OPERATORS.GREATER_THAN, 2.1, '2.0', true], 29 | [CONDITION_OPERATORS.GREATER_THAN, 2.1, '2.1', false], 30 | [CONDITION_OPERATORS.GREATER_THAN, 2.0, '2.1', false], 31 | [CONDITION_OPERATORS.GREATER_THAN_INCLUSIVE, 2, '1', true], 32 | [CONDITION_OPERATORS.GREATER_THAN_INCLUSIVE, 1, '1', true], 33 | [CONDITION_OPERATORS.GREATER_THAN_INCLUSIVE, 0, '1', false], 34 | [CONDITION_OPERATORS.GREATER_THAN_INCLUSIVE, 2.1, '2.0', true], 35 | [CONDITION_OPERATORS.GREATER_THAN_INCLUSIVE, 2.1, '2.1', true], 36 | [CONDITION_OPERATORS.GREATER_THAN_INCLUSIVE, 2.0, '2.1', false], 37 | [CONDITION_OPERATORS.LESS_THAN, 1, '2', true], 38 | [CONDITION_OPERATORS.LESS_THAN, 1, '1', false], 39 | [CONDITION_OPERATORS.LESS_THAN, 1, '0', false], 40 | [CONDITION_OPERATORS.LESS_THAN, 2.0, '2.1', true], 41 | [CONDITION_OPERATORS.LESS_THAN, 2.1, '2.1', false], 42 | [CONDITION_OPERATORS.LESS_THAN, 2.1, '2.0', false], 43 | [CONDITION_OPERATORS.LESS_THAN_INCLUSIVE, 1, '2', true], 44 | [CONDITION_OPERATORS.LESS_THAN_INCLUSIVE, 1, '1', true], 45 | [CONDITION_OPERATORS.LESS_THAN_INCLUSIVE, 1, '0', false], 46 | [CONDITION_OPERATORS.LESS_THAN_INCLUSIVE, 2.0, '2.1', true], 47 | [CONDITION_OPERATORS.LESS_THAN_INCLUSIVE, 2.1, '2.1', true], 48 | [CONDITION_OPERATORS.LESS_THAN_INCLUSIVE, 2.1, '2.0', false], 49 | [CONDITION_OPERATORS.NOT_EQUAL, 'bar', 'baz', true], 50 | [CONDITION_OPERATORS.NOT_EQUAL, 'bar', 'bar', false], 51 | [CONDITION_OPERATORS.NOT_EQUAL, 1, '2', true], 52 | [CONDITION_OPERATORS.NOT_EQUAL, 1, '1', false], 53 | [CONDITION_OPERATORS.NOT_EQUAL, true, 'false', true], 54 | [CONDITION_OPERATORS.NOT_EQUAL, false, 'true', true], 55 | [CONDITION_OPERATORS.NOT_EQUAL, false, 'false', false], 56 | [CONDITION_OPERATORS.NOT_EQUAL, true, 'true', false], 57 | [CONDITION_OPERATORS.CONTAINS, 'bar', 'b', true], 58 | [CONDITION_OPERATORS.CONTAINS, 'bar', 'bar', true], 59 | [CONDITION_OPERATORS.CONTAINS, 'bar', 'baz', false], 60 | [CONDITION_OPERATORS.CONTAINS, null, 'foo', false], 61 | [CONDITION_OPERATORS.NOT_CONTAINS, 'bar', 'b', false], 62 | [CONDITION_OPERATORS.NOT_CONTAINS, 'bar', 'bar', false], 63 | [CONDITION_OPERATORS.NOT_CONTAINS, 'bar', 'baz', true], 64 | [CONDITION_OPERATORS.NOT_CONTAINS, null, 'foo', false], 65 | [CONDITION_OPERATORS.REGEX, 'foo', '[a-z]+', true], 66 | [CONDITION_OPERATORS.REGEX, 'FOO', '[a-z]+', false], 67 | [CONDITION_OPERATORS.REGEX, null, '[a-z]+', false], 68 | [CONDITION_OPERATORS.EQUAL, "1.0.0", "1.0.0:semver", true], 69 | [CONDITION_OPERATORS.EQUAL, "1.0.0", "1.0.0:semver", true], 70 | [CONDITION_OPERATORS.EQUAL, "1.0.0", "1.0.1:semver", false], 71 | [CONDITION_OPERATORS.NOT_EQUAL, "1.0.0", "1.0.0:semver", false], 72 | [CONDITION_OPERATORS.NOT_EQUAL, "1.0.0", "1.0.1:semver", true], 73 | [CONDITION_OPERATORS.GREATER_THAN, "1.0.1", "1.0.0:semver", true], 74 | [CONDITION_OPERATORS.GREATER_THAN, "1.0.0", "1.0.0-beta:semver", true], 75 | [CONDITION_OPERATORS.GREATER_THAN, "1.0.1", "1.2.0:semver", false], 76 | [CONDITION_OPERATORS.GREATER_THAN, "1.0.1", "1.0.1:semver", false], 77 | [CONDITION_OPERATORS.GREATER_THAN, "1.2.4", "1.2.3-pre.2+build.4:semver", true], 78 | [CONDITION_OPERATORS.LESS_THAN, "1.0.0", "1.0.1:semver", true], 79 | [CONDITION_OPERATORS.LESS_THAN, "1.0.0", "1.0.0:semver", false], 80 | [CONDITION_OPERATORS.LESS_THAN, "1.0.1", "1.0.0:semver", false], 81 | [CONDITION_OPERATORS.LESS_THAN, "1.0.0-rc.2", "1.0.0-rc.3:semver", true], 82 | [CONDITION_OPERATORS.GREATER_THAN_INCLUSIVE, "1.0.1", "1.0.0:semver", true], 83 | [CONDITION_OPERATORS.GREATER_THAN_INCLUSIVE, "1.0.1", "1.2.0:semver", false], 84 | [CONDITION_OPERATORS.GREATER_THAN_INCLUSIVE, "1.0.1", "1.0.1:semver", true], 85 | [CONDITION_OPERATORS.LESS_THAN_INCLUSIVE, "1.0.0", "1.0.1:semver", true], 86 | [CONDITION_OPERATORS.LESS_THAN_INCLUSIVE, "1.0.0", "1.0.0:semver", true], 87 | [CONDITION_OPERATORS.LESS_THAN_INCLUSIVE, "1.0.1", "1.0.0:semver", false], 88 | [CONDITION_OPERATORS.MODULO, 1, "2|0", false], 89 | [CONDITION_OPERATORS.MODULO, 2, "2|0", true], 90 | [CONDITION_OPERATORS.MODULO, 3, "2|0", false], 91 | [CONDITION_OPERATORS.MODULO, 34.2, "4|3", false], 92 | [CONDITION_OPERATORS.MODULO, 35.0, "4|3", true], 93 | [CONDITION_OPERATORS.MODULO, "foo", "4|3", false], 94 | [CONDITION_OPERATORS.MODULO, 35.0, "foo|bar", false], 95 | [CONDITION_OPERATORS.IN, "foo", "", false], 96 | [CONDITION_OPERATORS.IN, "foo", "foo, bar", true], 97 | [CONDITION_OPERATORS.IN, "foo", "foo", true], 98 | [CONDITION_OPERATORS.IN, 1, "1,2,3,4", true], 99 | [CONDITION_OPERATORS.IN, 1, "", false], 100 | [CONDITION_OPERATORS.IN, 1, "1", true], 101 | ['BAD_OP', 'a', 'a', false] 102 | ]; 103 | 104 | test('test_segment_condition_matches_trait_value', () => { 105 | for (const testCase of conditionMatchCases) { 106 | const [operator, traitValue, conditionValue, expectedResult] = testCase 107 | expect( 108 | new SegmentConditionModel(operator, conditionValue, 'foo').matchesTraitValue( 109 | traitValue 110 | ) 111 | ).toBe(expectedResult); 112 | } 113 | }); 114 | 115 | test('test_segment_rule_none', () => { 116 | const testCases: [boolean[], boolean][] = [ 117 | [[], true], 118 | [[false], true], 119 | [[false, false], true], 120 | [[false, true], false], 121 | [[true, true], false] 122 | ]; 123 | 124 | for (const testCase of testCases) { 125 | expect(SegmentRuleModel.none(testCase[0])).toBe(testCase[1]); 126 | } 127 | }); 128 | 129 | test('test_segment_rule_matching_function', () => { 130 | const testCases: [string, CallableFunction][] = [ 131 | [ALL_RULE, all], 132 | [ANY_RULE, any], 133 | [NONE_RULE, SegmentRuleModel.none] 134 | ]; 135 | 136 | for (const testCase of testCases) { 137 | expect(new SegmentRuleModel(testCase[0]).matchingFunction()).toBe(testCase[1]); 138 | } 139 | }); 140 | -------------------------------------------------------------------------------- /tests/engine/unit/segments/util.ts: -------------------------------------------------------------------------------- 1 | import { ALL_RULE, ANY_RULE, EQUAL } from '../../../../flagsmith-engine/segments/constants'; 2 | import { SegmentModel } from '../../../../flagsmith-engine/segments/models'; 3 | import { buildSegmentModel } from '../../../../flagsmith-engine/segments/util'; 4 | 5 | export const traitKey1 = 'email'; 6 | export const traitValue1 = 'user@example.com'; 7 | 8 | export const traitKey2 = 'num_purchase'; 9 | export const traitValue2 = '12'; 10 | 11 | export const traitKey_3 = 'date_joined'; 12 | export const traitValue3 = '2021-01-01'; 13 | 14 | export const emptySegment = new SegmentModel(1, 'empty_segment'); 15 | 16 | export const segmentSingleCondition = buildSegmentModel({ 17 | id: 2, 18 | name: 'segment_one_condition', 19 | rules: [ 20 | { 21 | type: ALL_RULE, 22 | conditions: [ 23 | { 24 | operator: EQUAL, 25 | property_: traitKey1, 26 | value: traitValue1 27 | } 28 | ] 29 | } 30 | ] 31 | }); 32 | 33 | export const segmentMultipleConditionsAll = buildSegmentModel({ 34 | id: 3, 35 | name: 'segment_multiple_conditions_all', 36 | rules: [ 37 | { 38 | type: ALL_RULE, 39 | conditions: [ 40 | { 41 | operator: EQUAL, 42 | property_: traitKey1, 43 | value: traitValue1 44 | }, 45 | { 46 | operator: EQUAL, 47 | property_: traitKey2, 48 | value: traitValue2 49 | } 50 | ] 51 | } 52 | ] 53 | }); 54 | 55 | export const segmentMultipleConditionsAny = buildSegmentModel({ 56 | id: 4, 57 | name: 'segment_multiple_conditions_any', 58 | rules: [ 59 | { 60 | type: ANY_RULE, 61 | conditions: [ 62 | { 63 | operator: EQUAL, 64 | property_: traitKey1, 65 | value: traitValue1 66 | }, 67 | { 68 | operator: EQUAL, 69 | property_: traitKey2, 70 | value: traitValue2 71 | } 72 | ] 73 | } 74 | ] 75 | }); 76 | 77 | export const segmentNestedRules = buildSegmentModel({ 78 | id: 5, 79 | name: 'segment_nested_rules_all', 80 | rules: [ 81 | { 82 | type: ALL_RULE, 83 | rules: [ 84 | { 85 | type: ALL_RULE, 86 | conditions: [ 87 | { 88 | operator: EQUAL, 89 | property_: traitKey1, 90 | value: traitValue1 91 | }, 92 | { 93 | operator: EQUAL, 94 | property_: traitKey2, 95 | value: traitValue2 96 | } 97 | ] 98 | }, 99 | { 100 | type: ALL_RULE, 101 | conditions: [ 102 | { 103 | operator: EQUAL, 104 | property_: traitKey_3, 105 | value: traitValue3 106 | } 107 | ] 108 | } 109 | ] 110 | } 111 | ] 112 | }); 113 | 114 | export const segmentConditionsAndNestedRules = buildSegmentModel({ 115 | id: 6, 116 | name: 'segment_multiple_conditions_all_and_nested_rules', 117 | rules: [ 118 | { 119 | type: ALL_RULE, 120 | conditions: [ 121 | { 122 | operator: EQUAL, 123 | property_: traitKey1, 124 | value: traitValue1 125 | } 126 | ], 127 | rules: [ 128 | { 129 | type: ALL_RULE, 130 | conditions: [ 131 | { 132 | operator: EQUAL, 133 | property_: traitKey2, 134 | value: traitValue2 135 | } 136 | ] 137 | }, 138 | { 139 | type: ALL_RULE, 140 | conditions: [ 141 | { 142 | operator: EQUAL, 143 | property_: traitKey_3, 144 | value: traitValue3 145 | } 146 | ] 147 | } 148 | ] 149 | } 150 | ] 151 | }); 152 | -------------------------------------------------------------------------------- /tests/engine/unit/utils.ts: -------------------------------------------------------------------------------- 1 | import { EnvironmentModel } from '../../../flagsmith-engine/environments/models'; 2 | import { CONSTANTS } from '../../../flagsmith-engine/features/constants'; 3 | import { FeatureModel, FeatureStateModel } from '../../../flagsmith-engine/features/models'; 4 | import { IdentityModel } from '../../../flagsmith-engine/identities/models'; 5 | import { TraitModel } from '../../../flagsmith-engine/identities/traits/models'; 6 | import { OrganisationModel } from '../../../flagsmith-engine/organisations/models'; 7 | import { ProjectModel } from '../../../flagsmith-engine/projects/models'; 8 | import { ALL_RULE, EQUAL } from '../../../flagsmith-engine/segments/constants'; 9 | import { 10 | SegmentConditionModel, 11 | SegmentModel, 12 | SegmentRuleModel 13 | } from '../../../flagsmith-engine/segments/models'; 14 | 15 | export const segmentConditionProperty = 'foo'; 16 | export const segmentConditionStringValue = 'bar'; 17 | 18 | export function segmentCondition() { 19 | return new SegmentConditionModel(EQUAL, segmentConditionStringValue, segmentConditionProperty); 20 | } 21 | 22 | export function traitMatchingSegment() { 23 | return new TraitModel(segmentCondition().property_ as string, segmentCondition().value); 24 | } 25 | 26 | export function organisation() { 27 | return new OrganisationModel(1, 'test Org', true, false, true); 28 | } 29 | 30 | export function segmentRule() { 31 | const rule = new SegmentRuleModel(ALL_RULE); 32 | rule.conditions = [segmentCondition()]; 33 | return rule; 34 | } 35 | 36 | export function segment() { 37 | const segment = new SegmentModel(1, 'test name'); 38 | segment.rules = [segmentRule()]; 39 | return segment; 40 | } 41 | 42 | export function project() { 43 | const project = new ProjectModel(1, 'test project', false, organisation()); 44 | project.segments = [segment()]; 45 | return project; 46 | } 47 | 48 | export function feature1() { 49 | return new FeatureModel(1, 'feature_1', CONSTANTS.STANDARD); 50 | } 51 | 52 | export function feature2() { 53 | return new FeatureModel(2, 'feature_2', CONSTANTS.STANDARD); 54 | } 55 | 56 | export function environment() { 57 | const env = new EnvironmentModel(1, 'api-key', project()); 58 | 59 | env.featureStates = [ 60 | new FeatureStateModel(feature1(), true, 1), 61 | new FeatureStateModel(feature2(), false, 2) 62 | ]; 63 | 64 | return env; 65 | } 66 | 67 | export function identity() { 68 | return new IdentityModel(Date.now().toString(), [], [], environment().apiKey, 'identity_1'); 69 | } 70 | 71 | export function identityInSegment() { 72 | const identity = new IdentityModel( 73 | Date.now().toString(), 74 | [], 75 | [], 76 | environment().apiKey, 77 | 'identity_2' 78 | ); 79 | 80 | identity.identityTraits = [traitMatchingSegment()]; 81 | 82 | return identity; 83 | } 84 | 85 | export function getEnvironmentFeatureStateForFeatureByName( 86 | environment: EnvironmentModel, 87 | feature_name: string 88 | ): FeatureStateModel | undefined { 89 | const features = environment.featureStates.filter(fs => fs.feature.name === feature_name); 90 | return features[0]; 91 | } 92 | 93 | export function getEnvironmentFeatureStateForFeature( 94 | environment: EnvironmentModel, 95 | feature: FeatureModel 96 | ): FeatureStateModel | undefined { 97 | const f = environment.featureStates.find(f => f.feature === feature); 98 | return f; 99 | } 100 | 101 | export function segmentOverrideFs() { 102 | const fs = new FeatureStateModel(feature1(), false, 4); 103 | fs.setValue('segment_override'); 104 | return fs; 105 | } 106 | 107 | export function environmentWithSegmentOverride(): EnvironmentModel { 108 | const env = environment(); 109 | const segm = segment(); 110 | 111 | segm.featureStates.push(segmentOverrideFs()); 112 | env.project.segments.push(segm); 113 | return env; 114 | } 115 | -------------------------------------------------------------------------------- /tests/engine/unit/utils/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID as uuidv4 } from 'node:crypto'; 2 | import { getHashedPercentateForObjIds } from '../../../../flagsmith-engine/utils/hashing/index.js'; 3 | 4 | describe('getHashedPercentageForObjIds', () => { 5 | it.each([ 6 | [[12, 93]], 7 | [[uuidv4(), 99]], 8 | [[99, uuidv4()]], 9 | [[uuidv4(), uuidv4()]] 10 | ])('returns x where 0 <= x < 100', (objIds: (string|number)[]) => { 11 | let result = getHashedPercentateForObjIds(objIds); 12 | expect(result).toBeLessThan(100); 13 | expect(result).toBeGreaterThanOrEqual(0); 14 | }); 15 | 16 | it.each([ 17 | [[12, 93]], 18 | [[uuidv4(), 99]], 19 | [[99, uuidv4()]], 20 | [[uuidv4(), uuidv4()]] 21 | ])('returns the same value each time', (objIds: (string|number)[]) => { 22 | let resultOne = getHashedPercentateForObjIds(objIds); 23 | let resultTwo = getHashedPercentateForObjIds(objIds); 24 | expect(resultOne).toEqual(resultTwo); 25 | }) 26 | 27 | it('is unique for different object ids', () => { 28 | let resultOne = getHashedPercentateForObjIds([14, 106]); 29 | let resultTwo = getHashedPercentateForObjIds([53, 200]); 30 | expect(resultOne).not.toEqual(resultTwo); 31 | }) 32 | 33 | it('is evenly distributed', () => { 34 | // copied from python test here: 35 | // https://github.com/Flagsmith/flagsmith-engine/blob/main/tests/unit/utils/test_utils_hashing.py#L56 36 | const testSample = 500; 37 | const numTestBuckets = 50; 38 | const testBucketSize = Math.floor(testSample / numTestBuckets) 39 | const errorFactor = 0.1 40 | 41 | // Given 42 | let objectIdPairs = Array.from(Array(testSample).keys()).flatMap(d => Array.from(Array(testSample).keys()).map(e => [d, e].flat())) 43 | 44 | // When 45 | let values = objectIdPairs.map((objIds) => getHashedPercentateForObjIds(objIds)); 46 | 47 | // Then 48 | for (let i = 0; i++; i < numTestBuckets) { 49 | let bucketStart = i * testBucketSize; 50 | let bucketEnd = (i + 1) * testBucketSize; 51 | let bucketValueLimit = Math.min( 52 | (i + 1) / numTestBuckets + errorFactor + ((i + 1) / numTestBuckets), 53 | 1 54 | ) 55 | 56 | for (let i = bucketStart; i++; i < bucketEnd) { 57 | expect(values[i]).toBeLessThanOrEqual(bucketValueLimit); 58 | } 59 | } 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /tests/sdk/analytics.test.ts: -------------------------------------------------------------------------------- 1 | import {analyticsProcessor, fetch} from './utils.js'; 2 | 3 | test('test_analytics_processor_track_feature_updates_analytics_data', () => { 4 | const aP = analyticsProcessor(); 5 | aP.trackFeature("myFeature"); 6 | expect(aP.analyticsData["myFeature"]).toBe(1); 7 | 8 | aP.trackFeature("myFeature"); 9 | expect(aP.analyticsData["myFeature"]).toBe(2); 10 | }); 11 | 12 | test('test_analytics_processor_flush_clears_analytics_data', async () => { 13 | const aP = analyticsProcessor(); 14 | aP.trackFeature("myFeature"); 15 | await aP.flush(); 16 | expect(aP.analyticsData).toStrictEqual({}); 17 | }); 18 | 19 | test('test_analytics_processor_flush_post_request_data_match_ananlytics_data', async () => { 20 | const aP = analyticsProcessor(); 21 | aP.trackFeature("myFeature1"); 22 | aP.trackFeature("myFeature2"); 23 | await aP.flush(); 24 | expect(fetch).toHaveBeenCalledTimes(1); 25 | expect(fetch).toHaveBeenCalledWith('http://testUrl/analytics/flags/', expect.objectContaining({ 26 | body: '{"myFeature1":1,"myFeature2":1}', 27 | headers: { 'Content-Type': 'application/json', 'X-Environment-Key': 'test-key' }, 28 | method: 'POST', 29 | })); 30 | }); 31 | 32 | vi.useFakeTimers() 33 | test('test_analytics_processor_flush_post_request_data_match_ananlytics_data_test', async () => { 34 | const aP = analyticsProcessor(); 35 | aP.trackFeature("myFeature1"); 36 | setTimeout(() => { 37 | aP.trackFeature("myFeature2"); 38 | expect(fetch).toHaveBeenCalledTimes(1); 39 | }, 15000); 40 | vi.runOnlyPendingTimers(); 41 | }); 42 | 43 | test('test_analytics_processor_flush_early_exit_if_analytics_data_is_empty', async () => { 44 | const aP = analyticsProcessor(); 45 | await aP.flush(); 46 | expect(fetch).not.toHaveBeenCalled(); 47 | }); 48 | 49 | 50 | test('errors in fetch sending analytics data are swallowed', async () => { 51 | // Given 52 | // we mock the fetch function to throw and error to mimick a network failure 53 | fetch.mockRejectedValue('some error'); 54 | 55 | // and create the processor and track a feature so there is some analytics data 56 | const processor = analyticsProcessor(); 57 | processor.trackFeature('myFeature'); 58 | 59 | // When 60 | // we flush the data to trigger the call to fetch 61 | await processor.flush(); 62 | 63 | // Then 64 | // we expect that fetch was called but the exception was handled 65 | expect(fetch).toHaveBeenCalled(); 66 | }) 67 | 68 | test('analytics is only flushed once even if requested concurrently', async () => { 69 | const processor = analyticsProcessor(); 70 | processor.trackFeature('myFeature'); 71 | fetch.mockImplementation(() => { 72 | return new Promise((resolve, _) => { 73 | setTimeout(resolve, 1000) 74 | }) 75 | }); 76 | const flushes = Promise.all([ 77 | processor.flush(), 78 | processor.flush(), 79 | ]) 80 | vi.runOnlyPendingTimers(); 81 | await flushes; 82 | expect(fetch).toHaveBeenCalledTimes(1) 83 | }) 84 | -------------------------------------------------------------------------------- /tests/sdk/data/environment.json: -------------------------------------------------------------------------------- 1 | { 2 | "api_key": "B62qaMZNwfiqT76p38ggrQ", 3 | "project": { 4 | "name": "Test project", 5 | "organisation": { 6 | "feature_analytics": false, 7 | "name": "Test Org", 8 | "id": 1, 9 | "persist_trait_data": true, 10 | "stop_serving_flags": false 11 | }, 12 | "id": 1, 13 | "hide_disabled_flags": false, 14 | "segments": [ 15 | { 16 | "name": "regular_segment", 17 | "feature_states": [ 18 | { 19 | "feature_state_value": "segment_override", 20 | "featurestate_uuid": "dd77a1ab-08cf-4743-8a3b-19e730444a14", 21 | "multivariate_feature_state_values": [], 22 | "django_id": 81027, 23 | "feature": { 24 | "name": "some_feature", 25 | "type": "STANDARD", 26 | "id": 1 27 | }, 28 | "enabled": false 29 | } 30 | ], 31 | "id": 1, 32 | "rules": [ 33 | { 34 | "type": "ALL", 35 | "conditions": [], 36 | "rules": [ 37 | { 38 | "type": "ANY", 39 | "conditions": [ 40 | { 41 | "value": "40", 42 | "property_": "age", 43 | "operator": "LESS_THAN" 44 | } 45 | ], 46 | "rules": [] 47 | } 48 | ] 49 | } 50 | ] 51 | } 52 | ] 53 | }, 54 | "segment_overrides": [], 55 | "id": 1, 56 | "feature_states": [ 57 | { 58 | "multivariate_feature_state_values": [], 59 | "feature_state_value": "some-value", 60 | "id": 1, 61 | "featurestate_uuid": "40eb539d-3713-4720-bbd4-829dbef10d51", 62 | "feature": { 63 | "name": "some_feature", 64 | "type": "STANDARD", 65 | "id": 1 66 | }, 67 | "feature_segment": null, 68 | "enabled": true 69 | }, 70 | { 71 | "multivariate_feature_state_values": [ 72 | { 73 | "percentage_allocation": 100, 74 | "multivariate_feature_option": { 75 | "value": "bar", 76 | "id": 1 77 | }, 78 | "mv_fs_value_uuid": "42d5cdf9-8ec9-4b8d-a3ca-fd43c64d5f05", 79 | "id": 1 80 | } 81 | ], 82 | "feature_state_value": "foo", 83 | "feature": { 84 | "name": "mv_feature", 85 | "type": "MULTIVARIATE", 86 | "id": 2 87 | }, 88 | "feature_segment": null, 89 | "featurestate_uuid": "96fc3503-09d7-48f1-a83b-2dc903d5c08a", 90 | "enabled": false 91 | } 92 | ], 93 | "identity_overrides": [ 94 | { 95 | "identifier": "overridden-id", 96 | "identity_uuid": "0f21cde8-63c5-4e50-baca-87897fa6cd01", 97 | "created_date": "2019-08-27T14:53:45.698555Z", 98 | "updated_at": "2023-07-14 16:12:00.000000", 99 | "environment_api_key": "B62qaMZNwfiqT76p38ggrQ", 100 | "identity_features": [ 101 | { 102 | "id": 1, 103 | "feature": { 104 | "id": 1, 105 | "name": "some_feature", 106 | "type": "STANDARD" 107 | }, 108 | "featurestate_uuid": "1bddb9a5-7e59-42c6-9be9-625fa369749f", 109 | "feature_state_value": "some-overridden-value", 110 | "enabled": false, 111 | "environment": 1, 112 | "identity": null, 113 | "feature_segment": null 114 | } 115 | ] 116 | } 117 | ] 118 | } -------------------------------------------------------------------------------- /tests/sdk/data/flags.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1, 4 | "feature": { 5 | "id": 1, 6 | "name": "some_feature", 7 | "created_date": "2019-08-27T14:53:45.698555Z", 8 | "initial_value": null, 9 | "description": null, 10 | "default_enabled": false, 11 | "type": "STANDARD", 12 | "project": 1 13 | }, 14 | "feature_state_value": "some-value", 15 | "enabled": true, 16 | "environment": 1, 17 | "identity": null, 18 | "feature_segment": null 19 | } 20 | ] -------------------------------------------------------------------------------- /tests/sdk/data/identities.json: -------------------------------------------------------------------------------- 1 | { 2 | "traits": [ 3 | { 4 | "id": 1, 5 | "trait_key": "some_trait", 6 | "trait_value": "some_value" 7 | } 8 | ], 9 | "flags": [ 10 | { 11 | "id": 1, 12 | "feature": { 13 | "id": 1, 14 | "name": "some_feature", 15 | "created_date": "2019-08-27T14:53:45.698555Z", 16 | "initial_value": null, 17 | "description": null, 18 | "default_enabled": false, 19 | "type": "STANDARD", 20 | "project": 1 21 | }, 22 | "feature_state_value": "some-value", 23 | "enabled": true, 24 | "environment": 1, 25 | "identity": null, 26 | "feature_segment": null 27 | } 28 | ] 29 | } -------------------------------------------------------------------------------- /tests/sdk/data/identity-with-transient-traits.json: -------------------------------------------------------------------------------- 1 | { 2 | "traits": [ 3 | { 4 | "id": 1, 5 | "trait_key": "some_trait", 6 | "trait_value": "some_value" 7 | }, 8 | { 9 | "id": 2, 10 | "trait_key": "transient_key", 11 | "trait_value": "transient_value", 12 | "transient": true 13 | }, 14 | { 15 | "id": 3, 16 | "trait_key": "explicitly_non_transient_trait", 17 | "trait_value": "non_transient_value", 18 | "transient": false 19 | } 20 | ], 21 | "flags": [ 22 | { 23 | "id": 1, 24 | "feature": { 25 | "id": 1, 26 | "name": "some_feature", 27 | "created_date": "2019-08-27T14:53:45.698555Z", 28 | "initial_value": null, 29 | "description": null, 30 | "default_enabled": false, 31 | "type": "STANDARD", 32 | "project": 1 33 | }, 34 | "feature_state_value": "some-identity-with-transient-trait-value", 35 | "enabled": true, 36 | "environment": 1, 37 | "identity": null, 38 | "feature_segment": null 39 | } 40 | ] 41 | } -------------------------------------------------------------------------------- /tests/sdk/data/offline-environment.json: -------------------------------------------------------------------------------- 1 | { 2 | "api_key": "B62qaMZNwfiqT76p38ggrQ", 3 | "project": { 4 | "name": "Test project", 5 | "organisation": { 6 | "feature_analytics": false, 7 | "name": "Test Org", 8 | "id": 1, 9 | "persist_trait_data": true, 10 | "stop_serving_flags": false 11 | }, 12 | "id": 1, 13 | "hide_disabled_flags": false, 14 | "segments": [ 15 | { 16 | "name": "regular_segment", 17 | "feature_states": [ 18 | { 19 | "feature_state_value": "segment_override", 20 | "multivariate_feature_state_values": [], 21 | "django_id": 81027, 22 | "feature": { 23 | "name": "some_feature", 24 | "type": "STANDARD", 25 | "id": 1 26 | }, 27 | "enabled": false 28 | } 29 | ], 30 | "id": 1, 31 | "rules": [ 32 | { 33 | "type": "ALL", 34 | "conditions": [], 35 | "rules": [ 36 | { 37 | "type": "ANY", 38 | "conditions": [ 39 | { 40 | "value": "40", 41 | "property_": "age", 42 | "operator": "LESS_THAN" 43 | } 44 | ], 45 | "rules": [] 46 | } 47 | ] 48 | } 49 | ] 50 | } 51 | ] 52 | }, 53 | "segment_overrides": [], 54 | "id": 1, 55 | "feature_states": [ 56 | { 57 | "multivariate_feature_state_values": [], 58 | "feature_state_value": "offline-value", 59 | "id": 1, 60 | "featurestate_uuid": "40eb539d-3713-4720-bbd4-829dbef10d51", 61 | "feature": { 62 | "name": "some_feature", 63 | "type": "STANDARD", 64 | "id": 1 65 | }, 66 | "feature_segment": null, 67 | "enabled": true 68 | }, 69 | { 70 | "multivariate_feature_state_values": [ 71 | { 72 | "percentage_allocation": 100, 73 | "multivariate_feature_option": { 74 | "value": "bar", 75 | "id": 1 76 | }, 77 | "mv_fs_value_uuid": "42d5cdf9-8ec9-4b8d-a3ca-fd43c64d5f05", 78 | "id": 1 79 | } 80 | ], 81 | "feature_state_value": "foo", 82 | "feature": { 83 | "name": "mv_feature", 84 | "type": "MULTIVARIATE", 85 | "id": 2 86 | }, 87 | "feature_segment": null, 88 | "featurestate_uuid": "96fc3503-09d7-48f1-a83b-2dc903d5c08a", 89 | "enabled": false 90 | } 91 | ] 92 | } 93 | -------------------------------------------------------------------------------- /tests/sdk/data/transient-identity.json: -------------------------------------------------------------------------------- 1 | { 2 | "traits": [ 3 | { 4 | "id": 1, 5 | "trait_key": "some_trait", 6 | "trait_value": "some_value" 7 | } 8 | ], 9 | "flags": [ 10 | { 11 | "id": 1, 12 | "feature": { 13 | "id": 1, 14 | "name": "some_feature", 15 | "created_date": "2019-08-27T14:53:45.698555Z", 16 | "initial_value": null, 17 | "description": null, 18 | "default_enabled": false, 19 | "type": "STANDARD", 20 | "project": 1 21 | }, 22 | "feature_state_value": "some-transient-identity-value", 23 | "enabled": false, 24 | "environment": 1, 25 | "identity": null, 26 | "feature_segment": null 27 | } 28 | ] 29 | } -------------------------------------------------------------------------------- /tests/sdk/flagsmith-cache.test.ts: -------------------------------------------------------------------------------- 1 | import { fetch, environmentJSON, environmentModel, flagsJSON, flagsmith, identitiesJSON, TestCache } from './utils.js'; 2 | 3 | test('test_empty_cache_not_read_but_populated', async () => { 4 | const cache = new TestCache(); 5 | const set = vi.spyOn(cache, 'set'); 6 | 7 | const flg = flagsmith({ cache }); 8 | const allFlags = (await flg.getEnvironmentFlags()).allFlags(); 9 | 10 | expect(set).toBeCalled(); 11 | expect(await cache.get('flags')).toBeTruthy(); 12 | 13 | expect(fetch).toBeCalledTimes(1); 14 | expect(allFlags[0].enabled).toBe(true); 15 | expect(allFlags[0].value).toBe('some-value'); 16 | expect(allFlags[0].featureName).toBe('some_feature'); 17 | }); 18 | 19 | test('test_api_not_called_when_cache_present', async () => { 20 | const cache = new TestCache(); 21 | const set = vi.spyOn(cache, 'set'); 22 | 23 | const flg = flagsmith({ cache }); 24 | await (await flg.getEnvironmentFlags()).allFlags(); 25 | const allFlags = await (await flg.getEnvironmentFlags()).allFlags(); 26 | 27 | expect(set).toBeCalled(); 28 | expect(await cache.get('flags')).toBeTruthy(); 29 | 30 | expect(fetch).toBeCalledTimes(1); 31 | expect(allFlags[0].enabled).toBe(true); 32 | expect(allFlags[0].value).toBe('some-value'); 33 | expect(allFlags[0].featureName).toBe('some_feature'); 34 | }); 35 | 36 | test('test_api_called_twice_when_no_cache', async () => { 37 | fetch.mockImplementation(() => Promise.resolve(new Response(flagsJSON))); 38 | 39 | const flg = flagsmith(); 40 | await (await flg.getEnvironmentFlags()).allFlags(); 41 | 42 | const allFlags = await (await flg.getEnvironmentFlags()).allFlags(); 43 | 44 | expect(fetch).toBeCalledTimes(2); 45 | expect(allFlags[0].enabled).toBe(true); 46 | expect(allFlags[0].value).toBe('some-value'); 47 | expect(allFlags[0].featureName).toBe('some_feature'); 48 | }); 49 | 50 | test('test_get_environment_flags_uses_local_environment_when_available', async () => { 51 | const cache = new TestCache(); 52 | const set = vi.spyOn(cache, 'set'); 53 | 54 | const flg = flagsmith({ cache, environmentKey: 'ser.key', enableLocalEvaluation: true }); 55 | const model = environmentModel(JSON.parse(environmentJSON)); 56 | const getEnvironment = vi.spyOn(flg, 'getEnvironment') 57 | getEnvironment.mockResolvedValue(model) 58 | 59 | const allFlags = (await flg.getEnvironmentFlags()).allFlags(); 60 | 61 | expect(set).toBeCalled(); 62 | expect(fetch).toBeCalledTimes(0); 63 | expect(getEnvironment).toBeCalledTimes(1); 64 | expect(allFlags[0].enabled).toBe(model.featureStates[0].enabled); 65 | expect(allFlags[0].value).toBe(model.featureStates[0].getValue()); 66 | expect(allFlags[0].featureName).toBe(model.featureStates[0].feature.name); 67 | }); 68 | 69 | test('test_cache_used_for_identity_flags', async () => { 70 | const cache = new TestCache(); 71 | const set = vi.spyOn(cache, 'set'); 72 | 73 | const identifier = 'identifier'; 74 | const traits = { some_trait: 'some_value' }; 75 | const flg = flagsmith({ cache }); 76 | 77 | (await flg.getIdentityFlags(identifier, traits)).allFlags(); 78 | const identityFlags = (await flg.getIdentityFlags(identifier, traits)).allFlags(); 79 | 80 | expect(set).toBeCalled(); 81 | expect(await cache.get('flags-identifier')).toBeTruthy(); 82 | 83 | expect(fetch).toBeCalledTimes(1); 84 | 85 | expect(identityFlags[0].enabled).toBe(true); 86 | expect(identityFlags[0].value).toBe('some-value'); 87 | expect(identityFlags[0].featureName).toBe('some_feature'); 88 | }); 89 | 90 | test('test_cache_used_for_identity_flags_local_evaluation', async () => { 91 | const cache = new TestCache(); 92 | const set = vi.spyOn(cache, 'set'); 93 | 94 | const identifier = 'identifier'; 95 | const traits = { some_trait: 'some_value' }; 96 | const flg = flagsmith({ 97 | cache, 98 | environmentKey: 'ser.key', 99 | enableLocalEvaluation: true, 100 | }); 101 | 102 | (await flg.getIdentityFlags(identifier, traits)).allFlags(); 103 | const identityFlags = (await flg.getIdentityFlags(identifier, traits)).allFlags(); 104 | 105 | expect(set).toBeCalled(); 106 | expect(await cache.get('flags-identifier')).toBeTruthy(); 107 | 108 | expect(fetch).toBeCalledTimes(1); 109 | 110 | expect(identityFlags[0].enabled).toBe(true); 111 | expect(identityFlags[0].value).toBe('some-value'); 112 | expect(identityFlags[0].featureName).toBe('some_feature'); 113 | }); 114 | -------------------------------------------------------------------------------- /tests/sdk/flagsmith-environment-flags.test.ts: -------------------------------------------------------------------------------- 1 | import Flagsmith from '../../sdk/index.js'; 2 | import { environmentJSON, environmentModel, flagsJSON, flagsmith, fetch } from './utils.js'; 3 | import { DefaultFlag } from '../../sdk/models.js'; 4 | 5 | vi.mock('../../sdk/polling_manager'); 6 | 7 | test('test_get_environment_flags_calls_api_when_no_local_environment', async () => { 8 | const flg = flagsmith(); 9 | const allFlags = await (await flg.getEnvironmentFlags()).allFlags(); 10 | 11 | expect(fetch).toBeCalledTimes(1); 12 | expect(allFlags[0].enabled).toBe(true); 13 | expect(allFlags[0].value).toBe('some-value'); 14 | expect(allFlags[0].featureName).toBe('some_feature'); 15 | }); 16 | 17 | test('test_default_flag_is_used_when_no_environment_flags_returned', async () => { 18 | fetch.mockResolvedValue(new Response(JSON.stringify([]))); 19 | 20 | const defaultFlag = new DefaultFlag('some-default-value', true); 21 | 22 | const defaultFlagHandler = (featureName: string) => defaultFlag; 23 | 24 | const flg = new Flagsmith({ 25 | environmentKey: 'key', 26 | defaultFlagHandler: defaultFlagHandler, 27 | customHeaders: { 28 | 'X-Test-Header': '1', 29 | } 30 | }); 31 | 32 | const flags = await flg.getEnvironmentFlags(); 33 | const flag = flags.getFlag('some_feature'); 34 | expect(flag.isDefault).toBe(true); 35 | expect(flag.enabled).toBe(defaultFlag.enabled); 36 | expect(flag.value).toBe(defaultFlag.value); 37 | }); 38 | 39 | test('test_analytics_processor_tracks_flags', async () => { 40 | const defaultFlag = new DefaultFlag('some-default-value', true); 41 | 42 | const defaultFlagHandler = (featureName: string) => defaultFlag; 43 | 44 | const flg = flagsmith({ 45 | environmentKey: 'key', 46 | defaultFlagHandler: defaultFlagHandler, 47 | enableAnalytics: true, 48 | }); 49 | 50 | const flags = await flg.getEnvironmentFlags(); 51 | const flag = flags.getFlag('some_feature'); 52 | 53 | expect(flag.isDefault).toBe(false); 54 | expect(flag.enabled).toBe(true); 55 | expect(flag.value).toBe('some-value'); 56 | }); 57 | 58 | test('test_getFeatureValue', async () => { 59 | const defaultFlag = new DefaultFlag('some-default-value', true); 60 | 61 | const defaultFlagHandler = (featureName: string) => defaultFlag; 62 | 63 | const flg = flagsmith({ 64 | environmentKey: 'key', 65 | defaultFlagHandler: defaultFlagHandler, 66 | enableAnalytics: true, 67 | }); 68 | 69 | const flags = await flg.getEnvironmentFlags(); 70 | const featureValue = flags.getFeatureValue('some_feature'); 71 | 72 | expect(featureValue).toBe('some-value'); 73 | }); 74 | 75 | test('test_throws_when_no_default_flag_handler_after_multiple_API_errors', async () => { 76 | fetch.mockRejectedValue('Error during fetching the API response'); 77 | 78 | const flg = flagsmith({ 79 | environmentKey: 'key', 80 | }); 81 | 82 | await expect(async () => { 83 | const flags = await flg.getEnvironmentFlags(); 84 | const flag = flags.getFlag('some_feature'); 85 | }).rejects.toThrow('getEnvironmentFlags failed and no default flag handler was provided'); 86 | }); 87 | 88 | test('test_non_200_response_raises_flagsmith_api_error', async () => { 89 | const errorResponse403 = new Response('403 Forbidden', { 90 | status: 403 91 | }); 92 | fetch.mockResolvedValue(errorResponse403); 93 | 94 | const flg = new Flagsmith({ 95 | environmentKey: 'some' 96 | }); 97 | 98 | await expect(flg.getEnvironmentFlags()).rejects.toThrow(); 99 | }); 100 | test('test_default_flag_is_not_used_when_environment_flags_returned', async () => { 101 | const defaultFlag = new DefaultFlag('some-default-value', true); 102 | 103 | const defaultFlagHandler = (featureName: string) => defaultFlag; 104 | 105 | const flg = flagsmith({ 106 | environmentKey: 'key', 107 | defaultFlagHandler: defaultFlagHandler 108 | }); 109 | 110 | const flags = await flg.getEnvironmentFlags(); 111 | const flag = flags.getFlag('some_feature'); 112 | 113 | expect(flag.isDefault).toBe(false); 114 | expect(flag.value).not.toBe(defaultFlag.value); 115 | expect(flag.value).toBe('some-value'); 116 | }); 117 | 118 | test('test_default_flag_is_used_when_bad_api_response_happens', async () => { 119 | fetch.mockResolvedValue(new Response('bad-data')); 120 | 121 | const defaultFlag = new DefaultFlag('some-default-value', true); 122 | 123 | const defaultFlagHandler = (featureName: string) => defaultFlag; 124 | 125 | const flg = new Flagsmith({ 126 | environmentKey: 'key', 127 | defaultFlagHandler: defaultFlagHandler 128 | }); 129 | 130 | const flags = await flg.getEnvironmentFlags(); 131 | const flag = flags.getFlag('some_feature'); 132 | 133 | expect(flag.isDefault).toBe(true); 134 | expect(flag.value).toBe(defaultFlag.value); 135 | }); 136 | 137 | test('test_local_evaluation', async () => { 138 | const defaultFlag = new DefaultFlag('some-default-value', true); 139 | 140 | const defaultFlagHandler = (featureName: string) => defaultFlag; 141 | 142 | const flg = flagsmith({ 143 | environmentKey: 'ser.key', 144 | enableLocalEvaluation: true, 145 | defaultFlagHandler: defaultFlagHandler 146 | }); 147 | 148 | const flags = await flg.getEnvironmentFlags(); 149 | const flag = flags.getFlag('some_feature'); 150 | 151 | expect(flag.isDefault).toBe(false); 152 | expect(flag.value).not.toBe(defaultFlag.value); 153 | expect(flag.value).toBe('some-value'); 154 | }); 155 | -------------------------------------------------------------------------------- /tests/sdk/flagsmith-identity-flags.test.ts: -------------------------------------------------------------------------------- 1 | import Flagsmith from '../../sdk/index.js'; 2 | import { 3 | fetch, 4 | environmentJSON, 5 | flagsmith, 6 | identitiesJSON, 7 | identityWithTransientTraitsJSON, 8 | transientIdentityJSON, 9 | badFetch 10 | } from './utils.js'; 11 | import { DefaultFlag } from '../../sdk/models.js'; 12 | 13 | vi.mock('../../sdk/polling_manager'); 14 | 15 | test('test_get_identity_flags_calls_api_when_no_local_environment_no_traits', async () => { 16 | const identifier = 'identifier'; 17 | 18 | const flg = flagsmith(); 19 | 20 | const identityFlags = (await flg.getIdentityFlags(identifier)).allFlags(); 21 | 22 | expect(identityFlags[0].enabled).toBe(true); 23 | expect(identityFlags[0].value).toBe('some-value'); 24 | expect(identityFlags[0].featureName).toBe('some_feature'); 25 | }); 26 | 27 | test('test_get_identity_flags_uses_environment_when_local_environment_no_traits', async () => { 28 | const identifier = 'identifier'; 29 | 30 | const flg = flagsmith({ 31 | environmentKey: 'ser.key', 32 | enableLocalEvaluation: true, 33 | }); 34 | 35 | 36 | const identityFlags = (await flg.getIdentityFlags(identifier)).allFlags(); 37 | 38 | expect(identityFlags[0].enabled).toBe(true); 39 | expect(identityFlags[0].value).toBe('some-value'); 40 | expect(identityFlags[0].featureName).toBe('some_feature'); 41 | }); 42 | 43 | test('test_get_identity_flags_calls_api_when_no_local_environment_with_traits', async () => { 44 | const identifier = 'identifier'; 45 | const traits = { some_trait: 'some_value' }; 46 | const flg = flagsmith(); 47 | 48 | const identityFlags = (await flg.getIdentityFlags(identifier, traits)).allFlags(); 49 | 50 | expect(identityFlags[0].enabled).toBe(true); 51 | expect(identityFlags[0].value).toBe('some-value'); 52 | expect(identityFlags[0].featureName).toBe('some_feature'); 53 | }); 54 | 55 | test('test_default_flag_is_not_used_when_identity_flags_returned', async () => { 56 | const defaultFlag = new DefaultFlag('some-default-value', true); 57 | 58 | const defaultFlagHandler = (featureName: string) => defaultFlag; 59 | 60 | const flg = flagsmith({ 61 | environmentKey: 'key', 62 | defaultFlagHandler: defaultFlagHandler 63 | }); 64 | 65 | const flags = await flg.getIdentityFlags('identifier'); 66 | const flag = flags.getFlag('some_feature'); 67 | 68 | expect(flag.isDefault).toBe(false); 69 | expect(flag.value).not.toBe(defaultFlag.value); 70 | expect(flag.value).toBe('some-value'); 71 | }); 72 | 73 | test('test_default_flag_is_used_when_no_identity_flags_returned', async () => { 74 | fetch.mockResolvedValue(new Response(JSON.stringify({ flags: [], traits: [] }))); 75 | 76 | const defaultFlag = new DefaultFlag('some-default-value', true); 77 | const defaultFlagHandler = (featureName: string) => defaultFlag; 78 | 79 | const flg = new Flagsmith({ 80 | environmentKey: 'key', 81 | defaultFlagHandler: defaultFlagHandler 82 | }); 83 | 84 | const flags = await flg.getIdentityFlags('identifier'); 85 | const flag = flags.getFlag('some_feature'); 86 | 87 | expect(flag.isDefault).toBe(true); 88 | expect(flag.value).toBe(defaultFlag.value); 89 | expect(flag.enabled).toBe(defaultFlag.enabled); 90 | }); 91 | 92 | test('test_default_flag_is_used_when_no_identity_flags_returned_due_to_error', async () => { 93 | fetch.mockResolvedValue(new Response('bad data')) 94 | 95 | const defaultFlag = new DefaultFlag('some-default-value', true); 96 | const defaultFlagHandler = (featureName: string) => defaultFlag; 97 | 98 | const flg = new Flagsmith({ 99 | environmentKey: 'key', 100 | defaultFlagHandler: defaultFlagHandler 101 | }); 102 | 103 | const flags = await flg.getIdentityFlags('identifier'); 104 | const flag = flags.getFlag('some_feature'); 105 | 106 | expect(flag.isDefault).toBe(true); 107 | expect(flag.value).toBe(defaultFlag.value); 108 | expect(flag.enabled).toBe(defaultFlag.enabled); 109 | }); 110 | 111 | test('test_default_flag_is_used_when_no_identity_flags_returned_and_no_custom_default_flag_handler', async () => { 112 | fetch.mockResolvedValue(new Response(JSON.stringify({ flags: [], traits: [] }))) 113 | 114 | const flg = flagsmith({ 115 | environmentKey: 'key', 116 | }); 117 | 118 | const flags = await flg.getIdentityFlags('identifier'); 119 | const flag = flags.getFlag('some_feature'); 120 | 121 | expect(flag.isDefault).toBe(true); 122 | expect(flag.value).toBe(undefined); 123 | expect(flag.enabled).toBe(false); 124 | }); 125 | 126 | test('test_get_identity_flags_multivariate_value_with_local_evaluation_enabled', async () => { 127 | fetch.mockResolvedValue(new Response(environmentJSON)); 128 | const identifier = 'identifier'; 129 | 130 | const flg = flagsmith({ 131 | environmentKey: 'ser.key', 132 | enableLocalEvaluation: true, 133 | }); 134 | 135 | const identityFlags = (await flg.getIdentityFlags(identifier)) 136 | 137 | expect(identityFlags.getFeatureValue('mv_feature')).toBe('bar'); 138 | expect(identityFlags.isFeatureEnabled('mv_feature')).toBe(false); 139 | }); 140 | 141 | 142 | test('test_transient_identity', async () => { 143 | fetch.mockResolvedValue(new Response(transientIdentityJSON)); 144 | const identifier = 'transient_identifier'; 145 | const traits = { some_trait: 'some_value' }; 146 | const traitsInRequest = [{trait_key:Object.keys(traits)[0],trait_value:traits.some_trait}] 147 | const transient = true; 148 | const flg = flagsmith(); 149 | const identityFlags = (await flg.getIdentityFlags(identifier, traits, transient)).allFlags(); 150 | 151 | expect(fetch).toHaveBeenCalledWith( 152 | `https://edge.api.flagsmith.com/api/v1/identities/`, 153 | expect.objectContaining({ 154 | method: 'POST', 155 | headers: { 'Content-Type': 'application/json', 'X-Environment-Key': 'sometestfakekey' }, 156 | body: JSON.stringify({identifier, traits: traitsInRequest, transient }) 157 | } 158 | )); 159 | 160 | expect(identityFlags[0].enabled).toBe(false); 161 | expect(identityFlags[0].value).toBe('some-transient-identity-value'); 162 | expect(identityFlags[0].featureName).toBe('some_feature'); 163 | }); 164 | 165 | 166 | test('test_identity_with_transient_traits', async () => { 167 | fetch.mockResolvedValue(new Response(identityWithTransientTraitsJSON)); 168 | const identifier = 'transient_trait_identifier'; 169 | const traits = { 170 | some_trait: 'some_value', 171 | another_trait: {value: 'another_value', transient: true}, 172 | explicitly_non_transient_trait: {value: 'non_transient_value', transient: false} 173 | } 174 | const traitsInRequest = [ 175 | { 176 | trait_key:Object.keys(traits)[0], 177 | trait_value:traits.some_trait, 178 | }, 179 | { 180 | trait_key:Object.keys(traits)[1], 181 | trait_value:traits.another_trait.value, 182 | transient: true, 183 | }, 184 | { 185 | trait_key:Object.keys(traits)[2], 186 | trait_value:traits.explicitly_non_transient_trait.value, 187 | transient: false, 188 | }, 189 | ] 190 | const flg = flagsmith(); 191 | 192 | const identityFlags = (await flg.getIdentityFlags(identifier, traits)).allFlags(); 193 | expect(fetch).toHaveBeenCalledWith( 194 | `https://edge.api.flagsmith.com/api/v1/identities/`, 195 | expect.objectContaining({ 196 | method: 'POST', 197 | headers: { 'Content-Type': 'application/json', 'X-Environment-Key': 'sometestfakekey' }, 198 | body: JSON.stringify({identifier, traits: traitsInRequest}) 199 | }) 200 | ); 201 | expect(identityFlags[0].enabled).toBe(true); 202 | expect(identityFlags[0].value).toBe('some-identity-with-transient-trait-value'); 203 | expect(identityFlags[0].featureName).toBe('some_feature'); 204 | }); 205 | 206 | test('getIdentityFlags fails if API call failed and no default flag handler was provided', async () => { 207 | const flg = flagsmith({ 208 | fetch: badFetch, 209 | }) 210 | await expect(flg.getIdentityFlags('user')) 211 | .rejects 212 | .toThrow('getIdentityFlags failed and no default flag handler was provided') 213 | }) 214 | -------------------------------------------------------------------------------- /tests/sdk/flagsmith.test.ts: -------------------------------------------------------------------------------- 1 | import Flagsmith from '../../sdk/index.js'; 2 | import { EnvironmentDataPollingManager } from '../../sdk/polling_manager.js'; 3 | import { 4 | environmentJSON, 5 | environmentModel, 6 | flagsmith, 7 | fetch, 8 | offlineEnvironmentJSON, 9 | badFetch 10 | } from './utils.js'; 11 | import { DefaultFlag, Flags } from '../../sdk/models.js'; 12 | import { delay } from '../../sdk/utils.js'; 13 | import { EnvironmentModel } from '../../flagsmith-engine/environments/models.js'; 14 | import { BaseOfflineHandler } from '../../sdk/offline_handlers.js'; 15 | import { Agent } from 'undici'; 16 | 17 | vi.mock('../../sdk/polling_manager'); 18 | test('test_flagsmith_starts_polling_manager_on_init_if_enabled', () => { 19 | new Flagsmith({ 20 | environmentKey: 'ser.key', 21 | enableLocalEvaluation: true 22 | }); 23 | expect(EnvironmentDataPollingManager).toBeCalled(); 24 | }); 25 | 26 | test('test_flagsmith_local_evaluation_key_required', () => { 27 | expect(() => { 28 | new Flagsmith({ 29 | environmentKey: 'bad.key', 30 | enableLocalEvaluation: true 31 | }); 32 | }).toThrow('Using local evaluation requires a server-side environment key') 33 | }); 34 | 35 | test('test_update_environment_sets_environment', async () => { 36 | const flg = flagsmith({ 37 | environmentKey: 'ser.key', 38 | }); 39 | const model = environmentModel(JSON.parse(environmentJSON)); 40 | expect(await flg.getEnvironment()).toStrictEqual(model); 41 | }); 42 | 43 | test('test_set_agent_options', async () => { 44 | const agent = new Agent({}) 45 | 46 | fetch.mockImplementationOnce((url, options) => { 47 | //@ts-ignore I give up 48 | if (options.dispatcher !== agent) { 49 | throw new Error("Agent has not been set on retry fetch") 50 | } 51 | return Promise.resolve(new Response(environmentJSON)) 52 | }); 53 | 54 | const flg = flagsmith({ 55 | agent 56 | }); 57 | 58 | await flg.updateEnvironment(); 59 | }); 60 | 61 | test('test_get_identity_segments', async () => { 62 | const flg = flagsmith({ 63 | environmentKey: 'ser.key', 64 | enableLocalEvaluation: true 65 | }); 66 | const segments = await flg.getIdentitySegments('user', { age: 21 }); 67 | expect(segments[0].name).toEqual('regular_segment'); 68 | const segments2 = await flg.getIdentitySegments('user', { age: 41 }); 69 | expect(segments2.length).toEqual(0); 70 | }); 71 | 72 | 73 | test('test_get_identity_segments_empty_without_local_eval', async () => { 74 | const flg = new Flagsmith({ 75 | environmentKey: 'ser.key', 76 | enableLocalEvaluation: false 77 | }); 78 | const segments = await flg.getIdentitySegments('user', { age: 21 }); 79 | expect(segments.length).toBe(0); 80 | }); 81 | 82 | test('test_update_environment_uses_req_when_inited', async () => { 83 | const flg = flagsmith({ 84 | environmentKey: 'ser.key', 85 | enableLocalEvaluation: true, 86 | }); 87 | 88 | delay(400); 89 | 90 | expect(async () => { 91 | await flg.updateEnvironment(); 92 | }).not.toThrow(); 93 | }); 94 | 95 | test('test_isFeatureEnabled_environment', async () => { 96 | const defaultFlag = new DefaultFlag('some-default-value', true); 97 | 98 | const defaultFlagHandler = (featureName: string) => defaultFlag; 99 | 100 | const flg = new Flagsmith({ 101 | environmentKey: 'key', 102 | defaultFlagHandler: defaultFlagHandler, 103 | enableAnalytics: true, 104 | }); 105 | 106 | const flags = await flg.getEnvironmentFlags(); 107 | const featureValue = flags.isFeatureEnabled('some_feature'); 108 | 109 | expect(featureValue).toBe(true); 110 | }); 111 | 112 | test('test_fetch_recovers_after_single_API_error', async () => { 113 | fetch.mockRejectedValueOnce('Error during fetching the API response') 114 | const flg = flagsmith({ 115 | environmentKey: 'key', 116 | }); 117 | 118 | const flags = await flg.getEnvironmentFlags(); 119 | const flag = flags.getFlag('some_feature'); 120 | expect(flag.isDefault).toBe(false); 121 | expect(flag.enabled).toBe(true); 122 | expect(flag.value).toBe('some-value'); 123 | }); 124 | 125 | test.each([ 126 | [false, 'key'], 127 | [true, 'ser.key'] 128 | ])( 129 | 'default flag handler is used when API is unavailable (local evaluation = %s)', 130 | async (enableLocalEvaluation, environmentKey) => { 131 | const flg = flagsmith({ 132 | enableLocalEvaluation, 133 | environmentKey, 134 | defaultFlagHandler: () => new DefaultFlag('some-default-value', true), 135 | fetch: badFetch, 136 | }); 137 | const flags = await flg.getEnvironmentFlags(); 138 | const flag = flags.getFlag('some_feature'); 139 | expect(flag.isDefault).toBe(true); 140 | expect(flag.enabled).toBe(true); 141 | expect(flag.value).toBe('some-default-value'); 142 | } 143 | ); 144 | 145 | test('default flag handler used when timeout occurs', async () => { 146 | fetch.mockImplementation(async (...args) => { 147 | const forever = new Promise(() => {}) 148 | await forever 149 | throw new Error('waited forever') 150 | }); 151 | 152 | const defaultFlag = new DefaultFlag('some-default-value', true); 153 | 154 | const defaultFlagHandler = () => defaultFlag; 155 | 156 | const flg = flagsmith({ 157 | environmentKey: 'key', 158 | defaultFlagHandler: defaultFlagHandler, 159 | requestTimeoutSeconds: 0.0001, 160 | }); 161 | 162 | const flags = await flg.getEnvironmentFlags(); 163 | const flag = flags.getFlag('some_feature'); 164 | expect(flag.isDefault).toBe(true); 165 | expect(flag.enabled).toBe(defaultFlag.enabled); 166 | expect(flag.value).toBe(defaultFlag.value); 167 | }) 168 | 169 | test('request timeout uses default if not provided', async () => { 170 | 171 | const flg = new Flagsmith({ 172 | environmentKey: 'key', 173 | }); 174 | 175 | expect(flg.requestTimeoutMs).toBe(10000); 176 | }) 177 | 178 | test('test_throws_when_no_identityFlags_returned_due_to_error', async () => { 179 | const flg = flagsmith({ 180 | environmentKey: 'key', 181 | fetch: badFetch, 182 | }); 183 | 184 | await expect(async () => await flg.getIdentityFlags('identifier')) 185 | .rejects 186 | .toThrow(); 187 | }); 188 | 189 | test('test onEnvironmentChange is called when provided', async () => { 190 | const callback = vi.fn() 191 | 192 | const flg = new Flagsmith({ 193 | environmentKey: 'ser.key', 194 | enableLocalEvaluation: true, 195 | onEnvironmentChange: callback, 196 | }); 197 | 198 | fetch.mockRejectedValueOnce(new Error('API error')); 199 | await flg.updateEnvironment().catch(() => { 200 | // Expected rejection 201 | }); 202 | 203 | expect(callback).toBeCalled(); 204 | }); 205 | 206 | test('test onEnvironmentChange is called after error', async () => { 207 | const callback = vi.fn(); 208 | const flg = new Flagsmith({ 209 | environmentKey: 'ser.key', 210 | enableLocalEvaluation: true, 211 | onEnvironmentChange: callback, 212 | fetch: badFetch, 213 | }); 214 | await flg.updateEnvironment(); 215 | expect(callback).toHaveBeenCalled(); 216 | }); 217 | 218 | test('getIdentityFlags throws error if identifier is empty string', async () => { 219 | const flg = flagsmith({ 220 | environmentKey: 'key', 221 | }); 222 | 223 | await expect(flg.getIdentityFlags('')).rejects.toThrow('`identifier` argument is missing or invalid.'); 224 | }) 225 | 226 | test('getIdentitySegments throws error if identifier is empty string', async () => { 227 | const flg = flagsmith({ 228 | environmentKey: 'key', 229 | }); 230 | 231 | await expect(flg.getIdentitySegments('')).rejects.toThrow( 232 | '`identifier` argument is missing or invalid.' 233 | ); 234 | }); 235 | 236 | test('offline_mode', async () => { 237 | // Given 238 | const environment: EnvironmentModel = environmentModel(JSON.parse(offlineEnvironmentJSON)); 239 | 240 | class DummyOfflineHandler extends BaseOfflineHandler { 241 | getEnvironment(): EnvironmentModel { 242 | return environment; 243 | } 244 | } 245 | 246 | // When 247 | const flg = flagsmith({ offlineMode: true, offlineHandler: new DummyOfflineHandler() }); 248 | 249 | // Then 250 | // we can request the flags from the client successfully 251 | const environmentFlags: Flags = await flg.getEnvironmentFlags(); 252 | let flag = environmentFlags.getFlag('some_feature'); 253 | expect(flag.isDefault).toBe(false); 254 | expect(flag.enabled).toBe(true); 255 | expect(flag.value).toBe('offline-value'); 256 | 257 | 258 | const identityFlags: Flags = await flg.getIdentityFlags("identity"); 259 | flag = identityFlags.getFlag('some_feature'); 260 | expect(flag.isDefault).toBe(false); 261 | expect(flag.enabled).toBe(true); 262 | expect(flag.value).toBe('offline-value'); 263 | }); 264 | 265 | 266 | test('test_flagsmith_uses_offline_handler_if_set_and_no_api_response', async () => { 267 | // Given 268 | const environment: EnvironmentModel = environmentModel(JSON.parse(offlineEnvironmentJSON)); 269 | const api_url = 'http://some.flagsmith.com/api/v1/'; 270 | const mock_offline_handler = new BaseOfflineHandler(); 271 | 272 | vi.spyOn(mock_offline_handler, 'getEnvironment').mockReturnValue(environment); 273 | 274 | const flg = flagsmith({ 275 | environmentKey: 'some-key', 276 | apiUrl: api_url, 277 | offlineHandler: mock_offline_handler, 278 | offlineMode: true 279 | }); 280 | 281 | vi.spyOn(flg, 'getEnvironmentFlags'); 282 | vi.spyOn(flg, 'getIdentityFlags'); 283 | 284 | 285 | flg.environmentFlagsUrl = 'http://some.flagsmith.com/api/v1/environment-flags'; 286 | flg.identitiesUrl = 'http://some.flagsmith.com/api/v1/identities'; 287 | 288 | // Mock a 500 Internal Server Error response 289 | const errorResponse = new Response(null, { 290 | status: 500, 291 | statusText: 'Internal Server Error', 292 | }); 293 | 294 | fetch.mockResolvedValue(errorResponse); 295 | 296 | // When 297 | const environmentFlags: Flags = await flg.getEnvironmentFlags(); 298 | expect(mock_offline_handler.getEnvironment).toHaveBeenCalledTimes(1); 299 | const identityFlags: Flags = await flg.getIdentityFlags('identity', {}); 300 | 301 | // Then 302 | expect(flg.getEnvironmentFlags).toHaveBeenCalled(); 303 | expect(flg.getIdentityFlags).toHaveBeenCalled(); 304 | 305 | expect(environmentFlags.isFeatureEnabled('some_feature')).toBe(true); 306 | expect(environmentFlags.getFeatureValue('some_feature')).toBe('offline-value'); 307 | 308 | expect(identityFlags.isFeatureEnabled('some_feature')).toBe(true); 309 | expect(identityFlags.getFeatureValue('some_feature')).toBe('offline-value'); 310 | }); 311 | 312 | test('cannot use offline mode without offline handler', () => { 313 | // When and Then 314 | expect(() => new Flagsmith({ offlineMode: true, offlineHandler: undefined })).toThrowError( 315 | 'ValueError: offlineHandler must be provided to use offline mode.' 316 | ); 317 | }); 318 | 319 | test('cannot use both default handler and offline handler', () => { 320 | // When and Then 321 | expect(() => flagsmith({ 322 | offlineHandler: new BaseOfflineHandler(), 323 | defaultFlagHandler: () => new DefaultFlag('foo', true) 324 | })).toThrowError('ValueError: Cannot use both defaultFlagHandler and offlineHandler.'); 325 | }); 326 | 327 | test('cannot create Flagsmith client in remote evaluation without API key', () => { 328 | // When and Then 329 | expect(() => new Flagsmith({ environmentKey: '' })).toThrowError('ValueError: environmentKey is required.'); 330 | }); 331 | 332 | 333 | test('test_localEvaluation_true__identity_overrides_evaluated', async () => { 334 | const flg = flagsmith({ 335 | environmentKey: 'ser.key', 336 | enableLocalEvaluation: true 337 | }); 338 | 339 | await flg.updateEnvironment() 340 | const flags = await flg.getIdentityFlags('overridden-id'); 341 | expect(flags.getFeatureValue('some_feature')).toEqual('some-overridden-value'); 342 | }); 343 | 344 | test('getIdentityFlags succeeds if initial fetch failed then succeeded', async () => { 345 | const defaultFlagHandler = vi.fn(() => new DefaultFlag('mock-default-value', true)); 346 | 347 | fetch.mockRejectedValue(new Error('Initial API error')); 348 | const flg = flagsmith({ 349 | environmentKey: 'ser.key', 350 | enableLocalEvaluation: true, 351 | defaultFlagHandler 352 | }); 353 | 354 | const defaultFlags = await flg.getIdentityFlags('test-user'); 355 | expect(defaultFlags.isFeatureEnabled('mock-default-value')).toBe(true); 356 | expect(defaultFlagHandler).toHaveBeenCalled(); 357 | 358 | fetch.mockResolvedValue(new Response(environmentJSON)); 359 | await flg.getEnvironment(); 360 | const flags2 = await flg.getIdentityFlags('test-user'); 361 | expect(flags2.isFeatureEnabled('some_feature')).toBe(true); 362 | }); 363 | -------------------------------------------------------------------------------- /tests/sdk/offline-handlers.test.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import { LocalFileHandler } from '../../sdk/offline_handlers.js'; 3 | import { EnvironmentModel } from '../../flagsmith-engine/index.js'; 4 | 5 | import * as offlineEnvironment from "./data/offline-environment.json"; 6 | 7 | vi.mock('fs') 8 | 9 | const offlineEnvironmentString = JSON.stringify(offlineEnvironment) 10 | 11 | test('local file handler', () => { 12 | const environmentDocumentFilePath = '/some/path/environment.json'; 13 | 14 | // Mock the fs.readFileSync function to return environmentJson 15 | 16 | const readFileSyncMock = vi.spyOn(fs, 'readFileSync'); 17 | readFileSyncMock.mockImplementation(() => offlineEnvironmentString); 18 | 19 | // Given 20 | const localFileHandler = new LocalFileHandler(environmentDocumentFilePath); 21 | 22 | // When 23 | const environmentModel = localFileHandler.getEnvironment(); 24 | 25 | // Then 26 | expect(environmentModel).toBeInstanceOf(EnvironmentModel); 27 | expect(environmentModel.apiKey).toBe('B62qaMZNwfiqT76p38ggrQ'); 28 | expect(readFileSyncMock).toHaveBeenCalledWith(environmentDocumentFilePath, 'utf8'); 29 | 30 | // Restore the original implementation of fs.readFileSync 31 | readFileSyncMock.mockRestore(); 32 | }); 33 | -------------------------------------------------------------------------------- /tests/sdk/polling.test.ts: -------------------------------------------------------------------------------- 1 | import Flagsmith from '../../sdk/index.js'; 2 | import { EnvironmentDataPollingManager } from '../../sdk/polling_manager.js'; 3 | import { delay } from '../../sdk/utils.js'; 4 | vi.mock('../../sdk'); 5 | 6 | test('test_polling_manager_correctly_stops_if_never_started', async () => { 7 | const flagsmith = new Flagsmith({ 8 | environmentKey: 'key' 9 | }); 10 | 11 | const pollingManager = new EnvironmentDataPollingManager(flagsmith, 0.1); 12 | pollingManager.stop(); 13 | expect(flagsmith.updateEnvironment).not.toHaveBeenCalled(); 14 | }); 15 | 16 | test('test_polling_manager_calls_update_environment_on_start', async () => { 17 | const flagsmith = new Flagsmith({ 18 | environmentKey: 'key' 19 | }); 20 | 21 | const pollingManager = new EnvironmentDataPollingManager(flagsmith, 0.1); 22 | pollingManager.start(); 23 | await delay(500); 24 | pollingManager.stop(); 25 | expect(flagsmith.updateEnvironment).toHaveBeenCalled(); 26 | }); 27 | 28 | test('test_polling_manager_handles_double_start', async () => { 29 | const flagsmith = new Flagsmith({ 30 | environmentKey: 'key' 31 | }); 32 | 33 | const pollingManager = new EnvironmentDataPollingManager(flagsmith, 0.1); 34 | pollingManager.start(); 35 | await delay(100); 36 | pollingManager.start(); 37 | await delay(500); 38 | pollingManager.stop(); 39 | expect(flagsmith.updateEnvironment).toHaveBeenCalled(); 40 | }); 41 | 42 | 43 | test('test_polling_manager_calls_update_environment_on_each_refresh', async () => { 44 | const flagsmith = new Flagsmith({ 45 | environmentKey: 'key' 46 | }); 47 | 48 | const pollingManager = new EnvironmentDataPollingManager(flagsmith, 0.1); 49 | pollingManager.start(); 50 | await delay(450); 51 | pollingManager.stop(); 52 | expect(flagsmith.updateEnvironment).toHaveBeenCalledTimes(4); 53 | }); 54 | -------------------------------------------------------------------------------- /tests/sdk/utils.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs'; 2 | import { buildEnvironmentModel } from '../../flagsmith-engine/environments/util.js'; 3 | import { AnalyticsProcessor } from '../../sdk/analytics.js'; 4 | import Flagsmith, {FlagsmithConfig} from '../../sdk/index.js'; 5 | import { Fetch, FlagsmithCache } from '../../sdk/types.js'; 6 | import { Flags } from '../../sdk/models.js'; 7 | 8 | const DATA_DIR = __dirname + '/data/'; 9 | 10 | export class TestCache implements FlagsmithCache { 11 | cache: Record = {}; 12 | 13 | async get(name: string): Promise { 14 | return this.cache[name]; 15 | } 16 | 17 | async set(name: string, value: Flags) { 18 | this.cache[name] = value; 19 | } 20 | } 21 | 22 | export const fetch = vi.fn((url: string, options?: RequestInit) => { 23 | const headers = options?.headers as Record; 24 | if (!headers) throw new Error('missing request headers') 25 | const env = headers['X-Environment-Key']; 26 | if (!env) return Promise.resolve(new Response('missing x-environment-key header', { status: 404 })); 27 | if (url.includes('/environment-document')) { 28 | if (env.startsWith('ser.')) { 29 | return Promise.resolve(new Response(environmentJSON, { status: 200 })) 30 | } 31 | return Promise.resolve(new Response('environment-document called without a server-side key', { status: 401 })) 32 | } 33 | if (url.includes("/flags")) { 34 | return Promise.resolve(new Response(flagsJSON, { status: 200 })) 35 | } 36 | if (url.includes("/identities")) { 37 | return Promise.resolve(new Response(identitiesJSON, { status: 200 })) 38 | } 39 | return Promise.resolve(new Response('unknown url ' + url, { status: 404 })) 40 | }); 41 | 42 | export const badFetch: Fetch = () => { throw new Error('fetch failed')} 43 | 44 | export function analyticsProcessor() { 45 | return new AnalyticsProcessor({ 46 | environmentKey: 'test-key', 47 | analyticsUrl: 'http://testUrl/analytics/flags/', 48 | fetch: (url, options) => fetch(url.toString(), options), 49 | }); 50 | } 51 | 52 | export function apiKey(): string { 53 | return 'sometestfakekey'; 54 | } 55 | 56 | export function flagsmith(params: FlagsmithConfig = {}) { 57 | return new Flagsmith({ 58 | environmentKey: apiKey(), 59 | environmentRefreshIntervalSeconds: 0, 60 | requestRetryDelayMilliseconds: 0, 61 | fetch: (url, options) => fetch(url.toString(), options), 62 | ...params, 63 | }); 64 | } 65 | 66 | export const environmentJSON = readFileSync(DATA_DIR + 'environment.json', 'utf-8'); 67 | 68 | export const offlineEnvironmentJSON = readFileSync(DATA_DIR + 'offline-environment.json', 'utf-8') 69 | 70 | export function environmentModel(environmentJSON: any) { 71 | return buildEnvironmentModel(environmentJSON); 72 | } 73 | 74 | export const flagsJSON = readFileSync(DATA_DIR + 'flags.json', 'utf-8') 75 | 76 | export const identitiesJSON = readFileSync(DATA_DIR + 'identities.json', 'utf-8') 77 | 78 | export const transientIdentityJSON = readFileSync(DATA_DIR + 'transient-identity.json', 'utf-8') 79 | 80 | export const identityWithTransientTraitsJSON = readFileSync(DATA_DIR + 'identity-with-transient-traits.json', 'utf-8') 81 | -------------------------------------------------------------------------------- /tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./build/cjs", 5 | "module": "CommonJS" 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./build/esm", 5 | "module": "ESNext" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "./flagsmith-engine", 4 | "./sdk", 5 | "./index.ts" 6 | ], 7 | "compilerOptions": { 8 | "outDir": "./build", 9 | "target": "ES2022", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 10 | "downlevelIteration": true, // Used to allow typescript to interpret Object.entries 11 | /* Modules */ 12 | "module": "ESNext", 13 | "moduleResolution": "Node16", 14 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 15 | "declaration": true, 16 | "strict": true, /* Enable all strict type-checking options. */ 17 | "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ 18 | "skipLibCheck": true, /* Skip type checking all .d.ts files. */ 19 | "resolveJsonModule": true, 20 | "types": [ 21 | "vitest/globals" 22 | ] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, 6 | restoreMocks: true, 7 | coverage: { 8 | reporter: ['text'], 9 | exclude: [ 10 | 'build/**' 11 | ], 12 | include: [ 13 | 'sdk/**', 14 | 'flagsmith-engine/**', 15 | ] 16 | } 17 | }, 18 | }) 19 | --------------------------------------------------------------------------------