├── .nvmrc ├── .npmrc ├── .eslintignore ├── .release-please-manifest.json ├── CODEOWNERS ├── .husky └── pre-commit ├── lib ├── .gitignore ├── flagsmith │ ├── src │ │ └── readme.md │ ├── package-lock.json │ ├── README.md │ └── package.json ├── react-native-flagsmith │ ├── src │ │ └── readme.md │ ├── package-lock.json │ ├── package.json │ └── README.md └── flagsmith-es │ └── README.md ├── jest.config.js ├── utils ├── version.ts ├── ensureTrailingSlash.ts ├── set-dynatrace-value.ts ├── get-changes.ts ├── types.ts ├── async-storage.ts ├── angular-fetch.ts └── emitter.ts ├── examples └── README.md ├── nodemon.json ├── babel.config.js ├── flagsmith-core.d.ts ├── .prettierrc.json ├── next-middleware.ts ├── index.d.ts ├── test ├── isomorphic.test.tsx ├── test-utils │ └── remove-ids.ts ├── mocks │ ├── async-storage-mock.ts │ └── sync-storage-mock.ts ├── functions.test.ts ├── sentry.test.ts ├── flagsource-export.test.ts ├── data │ ├── flags.json │ ├── identities_test_identity_b.json │ ├── identities_test_transient_identity.json │ ├── identities_test_identity_a.json │ ├── identities_test_identity.json │ ├── identities_test_identity_with_traits.json │ └── identities_test_identity_with_transient_traits.json ├── test-constants.ts ├── types.test.ts ├── react-types.test.tsx ├── default-flags.test.ts ├── react.test.tsx ├── cache.test.ts ├── init.test.ts └── angular-fetch.test.ts ├── scripts └── write-version.js ├── .github └── workflows │ ├── release-please.yml │ ├── pull-request.yml │ └── publish.yml ├── tsconfig.json ├── index.react-native.ts ├── .gitignore ├── isomorphic.ts ├── evaluation-context.ts ├── index.ts ├── .eslintrc.js ├── react.d.ts ├── LICENSE ├── README.md ├── rollup.config.js ├── release-please-config.json ├── package.json ├── move-react.js ├── react.tsx ├── types.d.ts ├── patches └── reconnecting-eventsource+1.5.0.patch └── flagsmith-core.ts /.nvmrc: -------------------------------------------------------------------------------- 1 | 24 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | examples 2 | lib 3 | -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | {".":"10.0.0"} 2 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @flagsmith/flagsmith-front-end 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | npm run precommit 3 | -------------------------------------------------------------------------------- /lib/.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | *.ts 3 | *.mjs 4 | *.mjs.map 5 | *.tsx 6 | -------------------------------------------------------------------------------- /lib/flagsmith/src/readme.md: -------------------------------------------------------------------------------- 1 | This folder contains auto-generated sourcemaps. 2 | -------------------------------------------------------------------------------- /lib/react-native-flagsmith/src/readme.md: -------------------------------------------------------------------------------- 1 | This folder contains auto-generated sourcemaps. 2 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'jest-environment-jsdom', 3 | } 4 | -------------------------------------------------------------------------------- /utils/version.ts: -------------------------------------------------------------------------------- 1 | // Auto-generated by write-version.js 2 | export const SDK_VERSION = "10.0.0"; 3 | -------------------------------------------------------------------------------- /utils/ensureTrailingSlash.ts: -------------------------------------------------------------------------------- 1 | export function ensureTrailingSlash(str: string): string { 2 | return str.endsWith('/') ? str : str + '/'; 3 | } 4 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Flagsmith Javascript Examples 2 | 3 | Check out our [Javascript Examples repository](https://github.com/Flagsmith/flagsmith-js-examples). 4 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["."], 3 | "ignore": ["node_modules", "lib", "examples"], 4 | "ext": "js,ts,tsx,json", 5 | "exec": "npm run bundle" 6 | } 7 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', {targets: {node: 'current'}}], 4 | '@babel/preset-typescript', 5 | '@babel/preset-react', 6 | ], 7 | }; 8 | -------------------------------------------------------------------------------- /flagsmith-core.d.ts: -------------------------------------------------------------------------------- 1 | import { IFlagsmith } from "./types"; 2 | declare type Config = { 3 | fetch?: any; 4 | AsyncStorage?: any; 5 | }; 6 | export default function ({ fetch, AsyncStorage }: Config): IFlagsmith; 7 | export {}; 8 | -------------------------------------------------------------------------------- /lib/flagsmith-es/README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Flagsmith ESM Javascript Client 4 | 5 | This module is now deprecated and bundled into the [Flagsmith JS Client](https://www.npmjs.com/package/flagsmith). 6 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "proseWrap": "always", 3 | "singleQuote": true, 4 | "printWidth": 120, 5 | "tabWidth": 4, 6 | "semi": false, 7 | "overrides": [ 8 | { 9 | "files": "*.md", 10 | "options": { 11 | "tabWidth": 1 12 | } 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /next-middleware.ts: -------------------------------------------------------------------------------- 1 | import { IFlagsmith } from './types'; 2 | import core from './flagsmith-core' 3 | const flagsmith = core({}); 4 | export default flagsmith; 5 | export const createFlagsmithInstance = ():IFlagsmith=>{ 6 | return core({}) 7 | } 8 | export { FlagSource } from './flagsmith-core'; 9 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import { IFlagsmith } from './types'; 2 | declare const flagsmith: IFlagsmith; 3 | export default flagsmith; 4 | export * from './types'; 5 | export declare const createFlagsmithInstance: < 6 | F extends string = string, 7 | T extends string = string, 8 | >() => IFlagsmith; 9 | -------------------------------------------------------------------------------- /lib/flagsmith/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flagsmith", 3 | "version": "10.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "flagsmith", 9 | "version": "10.0.0", 10 | "license": "BSD-3-Clause" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test/isomorphic.test.tsx: -------------------------------------------------------------------------------- 1 | import flagsmithIsomorphic from '../lib/flagsmith/isomorphic'; 2 | import { getFlagsmith } from './test-constants'; 3 | 4 | describe('Flagsmith Isomorphic Import', () => { 5 | test('flagsmith is imported correctly', () => { 6 | const { initConfig } = getFlagsmith({}); 7 | flagsmithIsomorphic.init(initConfig) 8 | }) 9 | }) -------------------------------------------------------------------------------- /scripts/write-version.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const pkg = require('../lib/flagsmith/package.json'); 5 | 6 | const content = `// Auto-generated by write-version.js 7 | export const SDK_VERSION = "${pkg.version}"; 8 | `; 9 | 10 | fs.writeFileSync(path.join(__dirname, '../utils/version.ts'), content); 11 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | name: Update release PR 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | 12 | jobs: 13 | release-please: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: googleapis/release-please-action@v4 17 | with: 18 | token: ${{ secrets.RELEASE_PLEASE_GITHUB_TOKEN }} 19 | -------------------------------------------------------------------------------- /lib/react-native-flagsmith/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-flagsmith", 3 | "version": "10.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "react-native-flagsmith", 9 | "version": "10.0.0", 10 | "license": "BSD-3-Clause", 11 | "peerDependencies": { 12 | "react-native": ">=0.20.0" 13 | } 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./flagsmith/", 4 | "esModuleInterop": true, 5 | "skipLibCheck": true, 6 | "jsx": "react", 7 | "module": "ESNext", 8 | "declaration": true, 9 | "strict": true, 10 | "noEmit": true, 11 | "sourceMap": true, 12 | "moduleResolution": "node", 13 | "allowSyntheticDefaultImports": true, 14 | "forceConsistentCasingInFileNames": true 15 | }, 16 | "exclude": ["lib"] 17 | } 18 | -------------------------------------------------------------------------------- /test/test-utils/remove-ids.ts: -------------------------------------------------------------------------------- 1 | export default function removeIds(obj: Record) { 2 | if (typeof obj !== 'object' || obj === null) { 3 | return obj; 4 | } 5 | 6 | const newObj:Record = {}; 7 | 8 | for (const key in obj) { 9 | if (Object.prototype.hasOwnProperty.call(obj, key)) { 10 | if (key !== 'id') { 11 | newObj[key] = removeIds(obj[key]); 12 | } 13 | } 14 | } 15 | 16 | return newObj; 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/pull-request.yml: -------------------------------------------------------------------------------- 1 | name: Pull Requests 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize, reopened, ready_for_review] 6 | 7 | 8 | jobs: 9 | package: 10 | runs-on: ubuntu-latest 11 | name: Test 12 | 13 | steps: 14 | - name: Cloning repo 15 | uses: actions/checkout@v4 16 | 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version-file: .nvmrc 20 | 21 | - run: npm i 22 | - run: npm run build 23 | - run: npm test 24 | -------------------------------------------------------------------------------- /index.react-native.ts: -------------------------------------------------------------------------------- 1 | 2 | import core from './flagsmith-core' 3 | import RNEventSource from 'react-native-sse' 4 | // @ts-ignore 5 | global.FlagsmithEventSource = RNEventSource.default 6 | import _EventSource from 'reconnecting-eventsource'; 7 | export default core({ 8 | browserlessStorage: true, 9 | eventSource: _EventSource 10 | }); 11 | export const createFlagsmithInstance = ()=>{ 12 | return core({ 13 | browserlessStorage: true, 14 | eventSource: _EventSource 15 | }) 16 | } 17 | export { FlagSource } from './flagsmith-core'; 18 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish NPM Package 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | permissions: 9 | id-token: write 10 | contents: read 11 | 12 | jobs: 13 | package: 14 | runs-on: ubuntu-latest 15 | name: Publish NPM Package 16 | 17 | steps: 18 | - name: Cloning repo 19 | uses: actions/checkout@v5 20 | 21 | - uses: actions/setup-node@v4 22 | with: 23 | node-version-file: .nvmrc 24 | registry-url: 'https://registry.npmjs.org' # required for trusted publishing 25 | 26 | - run: npm i 27 | - run: npm run deploy 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OSX 2 | # 3 | .DS_Store 4 | 5 | # Xcode 6 | # 7 | build/ 8 | *.pbxuser 9 | !default.pbxuser 10 | *.mode1v3 11 | !default.mode1v3 12 | *.mode2v3 13 | !default.mode2v3 14 | *.perspectivev3 15 | !default.perspectivev3 16 | xcuserdata 17 | *.xccheckout 18 | *.moved-aside 19 | DerivedData 20 | *.hmap 21 | *.ipa 22 | *.xcuserstate 23 | project.xcworkspace 24 | 25 | # Android/IJ 26 | # 27 | *.iml 28 | .idea 29 | .gradle 30 | local.properties 31 | 32 | # node.js 33 | # 34 | node_modules/ 35 | npm-debug.log 36 | 37 | # BUCK 38 | buck-out/ 39 | \.buckd/ 40 | android/app/libs 41 | android/keystores/debug.keystore 42 | *.js.map 43 | 44 | # Utils 45 | utils/version.ts 46 | -------------------------------------------------------------------------------- /utils/set-dynatrace-value.ts: -------------------------------------------------------------------------------- 1 | // transforms any trait to match sendSessionProperties 2 | // https://www.dynatrace.com/support/doc/javascriptapi/interfaces/dtrum_types.DtrumApi.html#addActionProperties 3 | import { DynatraceObject } from '../types'; 4 | export default function (obj: DynatraceObject, trait: string, value: string|number|boolean|null|undefined) { 5 | let key: keyof DynatraceObject= 'shortString' 6 | let convertToString = true 7 | if (typeof value === 'number') { 8 | key = 'javaDouble' 9 | convertToString = false 10 | } 11 | // @ts-expect-error 12 | obj[key] = obj[key] || {} 13 | // @ts-expect-error 14 | obj[key][trait] = convertToString ? value+"":value 15 | } 16 | -------------------------------------------------------------------------------- /lib/react-native-flagsmith/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-flagsmith", 3 | "version": "10.0.0", 4 | "description": "Feature flagging to support continuous development", 5 | "main": "./index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/Flagsmith/flagsmith-js-client/" 9 | }, 10 | "keywords": [ 11 | "feature flagger", 12 | "continuous deployment" 13 | ], 14 | "author": "Flagsmith", 15 | "license": "BSD-3-Clause", 16 | "bugs": { 17 | "url": "https://github.com/Flagsmith/flagsmith-js-client/issues" 18 | }, 19 | "homepage": "https://flagsmith.com", 20 | "peerDependencies": { 21 | "react-native": ">=0.20.0" 22 | }, 23 | "types": "./index.d.ts" 24 | } 25 | -------------------------------------------------------------------------------- /test/mocks/async-storage-mock.ts: -------------------------------------------------------------------------------- 1 | export type Callback = (err: Error | null, val: string | null) => void; 2 | 3 | class MockAsyncStorage { 4 | store: Map; 5 | 6 | constructor() { 7 | this.store = new Map(); 8 | } 9 | 10 | getItem = jest.fn(async (k: string, cb?: Callback): Promise => { 11 | const val = this.store.get(k) || null; 12 | if (cb) cb(null, val); 13 | return Promise.resolve(val); 14 | }); 15 | 16 | setItem = jest.fn(async (k: string, v: string, cb?: Callback): Promise => { 17 | this.store.set(k, v); 18 | if (cb) cb(null, v); 19 | return Promise.resolve() 20 | }); 21 | } 22 | 23 | export default MockAsyncStorage; 24 | -------------------------------------------------------------------------------- /utils/get-changes.ts: -------------------------------------------------------------------------------- 1 | import { IFlags, Traits } from '../types'; 2 | import deepEqual from 'fast-deep-equal'; 3 | 4 | export default function(before: Traits | IFlags | undefined | null, after:Traits | IFlags | undefined | null) { 5 | const changedValues = Object.keys(after||{}).filter((flagKey)=>{ 6 | const beforeValue = before?.[flagKey] 7 | const afterValue = after?.[flagKey] 8 | return !deepEqual(beforeValue, afterValue) 9 | }) 10 | Object.keys(before||{}).filter((flagKey)=>{ 11 | if(!Object.keys(after||{}).includes(flagKey)) { 12 | changedValues.push(flagKey) 13 | } 14 | }) 15 | if (!Object.keys(changedValues).length) { 16 | return null 17 | } 18 | return changedValues 19 | } 20 | -------------------------------------------------------------------------------- /isomorphic.ts: -------------------------------------------------------------------------------- 1 | import AsyncStorage from "./utils/async-storage"; 2 | import {IFlagsmith} from "./types"; 3 | import core from './flagsmith-core' 4 | 5 | // @ts-ignore 6 | globalThis.FlagsmithEventSource = typeof EventSource !== 'undefined' ? EventSource : null; 7 | import eventSource from 'reconnecting-eventsource' 8 | 9 | 10 | const flagsmith: IFlagsmith = core({ 11 | AsyncStorage, 12 | eventSource: typeof window !=='undefined'?eventSource : null 13 | }); 14 | 15 | if (typeof window !== "undefined") { 16 | // @ts-ignore 17 | window.flagsmith = flagsmith; 18 | } 19 | export default flagsmith; 20 | 21 | export const createFlagsmithInstance = (): IFlagsmith => { 22 | return core({ 23 | AsyncStorage, 24 | eventSource: typeof window !=='undefined'?eventSource : null 25 | }) 26 | } 27 | export { FlagSource } from './flagsmith-core'; 28 | -------------------------------------------------------------------------------- /test/mocks/sync-storage-mock.ts: -------------------------------------------------------------------------------- 1 | 2 | export type Callback = (err: Error | null, val: string | null) => void; 3 | 4 | class MockAsyncStorage { 5 | store: Map; 6 | 7 | constructor() { 8 | this.store = new Map(); 9 | } 10 | 11 | getItem = jest.fn(async (k: string, cb?: Callback): Promise => { 12 | const val = this.store.get(k) || null; 13 | if (cb) cb(null, val); 14 | return Promise.resolve(val); 15 | }); 16 | 17 | setItem = jest.fn(async (k: string, v: string, cb?: Callback): Promise => { 18 | this.store.set(k, v); 19 | if (cb) cb(null, v); 20 | return Promise.resolve() 21 | }); 22 | 23 | getItemSync = jest.fn( (k: string): string|null => { 24 | return this.store.get(k) || null 25 | }); 26 | } 27 | 28 | export default MockAsyncStorage; 29 | -------------------------------------------------------------------------------- /evaluation-context.ts: -------------------------------------------------------------------------------- 1 | export interface EvaluationContext { 2 | environment?: null | EnvironmentEvaluationContext; 3 | feature?: null | FeatureEvaluationContext; 4 | identity?: null | IdentityEvaluationContext; 5 | [property: string]: any; 6 | } 7 | 8 | export interface EnvironmentEvaluationContext { 9 | apiKey: string; 10 | [property: string]: any; 11 | } 12 | 13 | export interface FeatureEvaluationContext { 14 | name: string; 15 | [property: string]: any; 16 | } 17 | 18 | export interface IdentityEvaluationContext { 19 | identifier?: null | string; 20 | traits?: { [key: string]: null | TraitEvaluationContext }; 21 | transient?: boolean | null; 22 | [property: string]: any; 23 | } 24 | 25 | export interface TraitEvaluationContext { 26 | transient?: boolean; 27 | value: any; 28 | [property: string]: any; 29 | } 30 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import { IFlagsmith } from './types'; 2 | 3 | // @ts-ignore 4 | globalThis.FlagsmithEventSource = typeof EventSource!== "undefined"? EventSource: null; 5 | 6 | import fetch from "unfetch" 7 | import AsyncStorage from "./utils/async-storage"; 8 | import core, { LikeFetch } from './flagsmith-core'; 9 | import _EventSource from 'reconnecting-eventsource' 10 | // @ts-expect-error 11 | const _fetch = fetch as LikeFetch 12 | const flagsmith = core({AsyncStorage, fetch:_fetch, eventSource:_EventSource}); 13 | if (typeof window !== "undefined") { 14 | // @ts-expect-error, some people wish to use flagsmith globally 15 | window.flagsmith = flagsmith; 16 | } 17 | 18 | export default flagsmith; 19 | export const createFlagsmithInstance = ():IFlagsmith=>{ 20 | return core({ AsyncStorage, fetch:_fetch, eventSource:_EventSource}) 21 | } 22 | export { FlagSource } from './flagsmith-core'; 23 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "es6": true, 4 | "browser": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:react/recommended", 10 | "plugin:react-hooks/recommended", 11 | "plugin:@typescript-eslint/recommended", 12 | ], 13 | "parser": "@typescript-eslint/parser", 14 | "parserOptions": { 15 | "ecmaVersion": 6, 16 | "sourceType": "module", 17 | "ecmaFeatures": { 18 | "legacyDecorators": true, 19 | "modules": true 20 | } 21 | }, 22 | "plugins": [ 23 | "react", 24 | "react-hooks", 25 | "@typescript-eslint" 26 | ], 27 | "rules": { 28 | "@typescript-eslint/ban-ts-comment": "off" 29 | }, 30 | "settings": { 31 | "react": { 32 | "version": "detect" 33 | } 34 | }, 35 | "globals": { 36 | 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /test/functions.test.ts: -------------------------------------------------------------------------------- 1 | import { getFlagsmith } from './test-constants'; 2 | 3 | describe('Flagsmith.functions', () => { 4 | 5 | beforeEach(() => { 6 | // Avoid mocks, but if you need to add them here 7 | }); 8 | test('should use a fallback when the feature is undefined', async () => { 9 | const onChange = jest.fn() 10 | const {flagsmith,initConfig, AsyncStorage,mockFetch} = getFlagsmith({onChange}) 11 | await flagsmith.init(initConfig); 12 | 13 | expect(flagsmith.getValue("deleted_feature",{fallback:"foo"})).toBe("foo"); 14 | expect(flagsmith.hasFeature("deleted_feature",{fallback:true})).toBe(true); 15 | expect(flagsmith.hasFeature("deleted_feature",{fallback:false})).toBe(false); 16 | expect(flagsmith.hasFeature("font_size",{fallback:false})).toBe(true); 17 | expect(flagsmith.getValue("font_size",{fallback:100})).toBe(16); 18 | expect(flagsmith.getValue("font_size")).toBe(16); 19 | expect(flagsmith.hasFeature("font_size")).toBe(true); 20 | }) 21 | }); 22 | -------------------------------------------------------------------------------- /test/sentry.test.ts: -------------------------------------------------------------------------------- 1 | import { waitFor } from '@testing-library/react'; 2 | import {defaultState, FLAGSMITH_KEY, getFlagsmith, getStateToCheck, identityState} from './test-constants'; 3 | import { promises as fs } from 'fs'; 4 | 5 | describe('Flagsmith.init', () => { 6 | beforeEach(() => { 7 | // Avoid mocks, but if you need to add them here 8 | }); 9 | test('should initialize with expected values', async () => { 10 | const onChange = jest.fn(); 11 | const { flagsmith, initConfig, AsyncStorage, mockFetch } = getFlagsmith({ onChange }); 12 | const addFeatureFlag = jest.fn(); 13 | const integration = { addFeatureFlag }; 14 | const client = { 15 | getIntegrationByName: jest.fn().mockReturnValue(integration), 16 | }; 17 | await flagsmith.init({...initConfig, sentryClient: client}); 18 | flagsmith.hasFeature("zero") 19 | flagsmith.hasFeature("hero") 20 | expect(addFeatureFlag).toHaveBeenCalledWith('zero', false); 21 | expect(addFeatureFlag).toHaveBeenCalledWith('hero', true); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /test/flagsource-export.test.ts: -------------------------------------------------------------------------------- 1 | import { FlagSource } from '../index'; 2 | import { FlagSource as FlagSourceIso } from '../isomorphic'; 3 | 4 | describe('FlagSource Export', () => { 5 | 6 | test('should export FlagSource enum with all values', () => { 7 | expect(FlagSource).toBeDefined(); 8 | expect(FlagSource.NONE).toBe('NONE'); 9 | expect(FlagSource.DEFAULT_FLAGS).toBe('DEFAULT_FLAGS'); 10 | expect(FlagSource.CACHE).toBe('CACHE'); 11 | expect(FlagSource.SERVER).toBe('SERVER'); 12 | }); 13 | 14 | test('should be usable in runtime value comparisons', () => { 15 | const source = 'NONE' as string; 16 | 17 | expect(source === FlagSource.NONE).toBe(true); 18 | expect(source === FlagSource.CACHE).toBe(false); 19 | 20 | const mySource: FlagSource = FlagSource.NONE; 21 | expect(mySource).toBe('NONE'); 22 | }); 23 | 24 | test('should export FlagSource from all entry points', () => { 25 | expect(FlagSourceIso).toBeDefined(); 26 | expect(FlagSourceIso.NONE).toBe('NONE'); 27 | expect(FlagSource.NONE).toBe(FlagSourceIso.NONE); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /utils/types.ts: -------------------------------------------------------------------------------- 1 | import { EvaluationContext, TraitEvaluationContext } from "../evaluation-context"; 2 | import { ClientEvaluationContext, ITraits, IFlagsmithTrait } from "../types"; 3 | 4 | export function isTraitEvaluationContext(trait: TraitEvaluationContext | IFlagsmithTrait): trait is TraitEvaluationContext { 5 | return !!trait && typeof trait == 'object' && trait.value !== undefined; 6 | } 7 | 8 | export function toTraitEvaluationContextObject(traits: ITraits): { [key: string]: null | TraitEvaluationContext } { 9 | return Object.fromEntries( 10 | Object.entries(traits).map( 11 | ([tKey, tValue]) => [tKey, isTraitEvaluationContext(tValue) ? tValue : {value: tValue}] 12 | ) 13 | ); 14 | } 15 | 16 | export function toEvaluationContext(clientEvaluationContext: ClientEvaluationContext): EvaluationContext { 17 | return { 18 | ...clientEvaluationContext, 19 | identity: !!clientEvaluationContext.identity ? { 20 | ...clientEvaluationContext.identity, 21 | traits: toTraitEvaluationContextObject(clientEvaluationContext.identity.traits || {}) 22 | } : undefined, 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /test/data/flags.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "feature": { 4 | "id": 1804, 5 | "name": "hero", 6 | "type": "STANDARD" 7 | }, 8 | "enabled": true, 9 | "feature_state_value": "https://s3-us-west-2.amazonaws.com/com.uppercut.hero-images/assets/0466/comps/466_03314.jpg" 10 | }, 11 | { 12 | "feature": { 13 | "id": 6149, 14 | "name": "font_size", 15 | "type": "STANDARD" 16 | }, 17 | "enabled": true, 18 | "feature_state_value": 16 19 | }, 20 | { 21 | "feature": { 22 | "id": 80317, 23 | "name": "json_value", 24 | "type": "STANDARD" 25 | }, 26 | "enabled": true, 27 | "feature_state_value": "{\"title\":\"Hello World\"}" 28 | }, 29 | { 30 | "feature": { 31 | "id": 80318, 32 | "name": "number_value", 33 | "type": "STANDARD" 34 | }, 35 | "enabled": true, 36 | "feature_state_value": 1 37 | }, 38 | { 39 | "feature": { 40 | "id": 80319, 41 | "name": "off_value", 42 | "type": "STANDARD" 43 | }, 44 | "enabled": false, 45 | "feature_state_value": null 46 | } 47 | ] -------------------------------------------------------------------------------- /react.d.ts: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { IFlagsmith, IFlagsmithTrait, IFlagsmithFeature, IState, LoadingState } from './types'; 3 | export * from './types'; 4 | export declare const FlagsmithContext: React.Context; 5 | export declare type FlagsmithContextType = { 6 | flagsmith: IFlagsmith; 7 | options?: Parameters['init']>[0]; 8 | serverState?: IState; 9 | children: React.ReactNode; 10 | }; 11 | type UseFlagsReturn< 12 | F extends string | Record, 13 | T extends string 14 | > = [F] extends [string] 15 | ? { 16 | [K in F]: IFlagsmithFeature; 17 | } & { 18 | [K in T]: IFlagsmithTrait; 19 | } 20 | : { 21 | [K in keyof F]: IFlagsmithFeature; 22 | } & { 23 | [K in T]: IFlagsmithTrait; 24 | }; 25 | export declare const FlagsmithProvider: FC; 26 | export declare function useFlags< 27 | F extends string | Record, 28 | T extends string = string 29 | >(_flags: readonly (F | keyof F)[], _traits?: readonly T[]): UseFlagsReturn; 30 | export declare const useFlagsmith: , 31 | T extends string = string>() => IFlagsmith; 32 | export declare const useFlagsmithLoading: () => LoadingState | undefined; 33 | -------------------------------------------------------------------------------- /lib/flagsmith/README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Flagsmith Javascript Client 4 | 5 | [![npm version](https://badge.fury.io/js/flagsmith.svg)](https://badge.fury.io/js/flagsmith) 6 | [![](https://data.jsdelivr.com/v1/package/npm/flagsmith/badge)](https://www.jsdelivr.com/package/npm/flagsmith) 7 | 8 | The web and SSR SDK clients for [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/javascript/](https://docs.flagsmith.com/clients/javascript/) 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 to us. 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 | ## Get in touch 23 | 24 | If you have any questions about our projects you can email support@flagsmith.com. 25 | 26 | ## Useful links 27 | 28 | [Website](https://www.flagsmith.com/) 29 | 30 | [Documentation](https://docs.flagsmith.com/) 31 | -------------------------------------------------------------------------------- /utils/async-storage.ts: -------------------------------------------------------------------------------- 1 | export type AsyncStorageType = { 2 | getItemSync?: (key:string)=>string|null 3 | getItem: (key:string, cb?:(err:Error|null, res:string|null)=>void)=>Promise 4 | setItem: (key:string, value: string)=>Promise 5 | } | null 6 | const AsyncStorage: AsyncStorageType = { 7 | getItemSync: function(key) { 8 | try { 9 | const data = localStorage.getItem(key); 10 | return data || null 11 | } catch (e) { 12 | return null 13 | } 14 | }, 15 | getItem: function (key, cb) { 16 | return new Promise((resolve, reject) => { 17 | try { 18 | const result = this.getItemSync!(key); 19 | cb?.(null, result) 20 | resolve(result) 21 | } catch (err) { 22 | cb && cb(err as Error, null); 23 | reject(err); 24 | } 25 | }); 26 | }, 27 | setItem: function (key:string, value:string, cb?: (err:Error|null, res:string|null)=>void) { 28 | return new Promise((resolve, reject) => { 29 | try { 30 | localStorage.setItem(key, value); 31 | cb && cb(null, value); 32 | resolve(value); 33 | } catch (err) { 34 | cb && cb(err as Error, null); 35 | reject(err); 36 | } 37 | }); 38 | } 39 | }; 40 | 41 | export default AsyncStorage 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /lib/react-native-flagsmith/README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Flagsmith React Native Client 4 | 5 | [![npm version](https://badge.fury.io/js/flagsmith.svg)](https://badge.fury.io/js/react-native-flagsmith) 6 | [![](https://data.jsdelivr.com/v1/package/npm/flagsmith/badge)](https://www.jsdelivr.com/package/npm/flagsmith) 7 | 8 | The React Native SDK client for [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/javascript/#npm-for-react-native](https://docs.flagsmith.com/clients/javascript/#npm-for-react-native) 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 to us. 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 | ## Get in touch 23 | 24 | If you have any questions about our projects you can email support@flagsmith.com. 25 | 26 | ## Useful links 27 | 28 | [Website](https://www.flagsmith.com/) 29 | 30 | [Documentation](https://docs.flagsmith.com/) 31 | -------------------------------------------------------------------------------- /test/data/identities_test_identity_b.json: -------------------------------------------------------------------------------- 1 | { 2 | "identifier": "test_identity_b", 3 | "flags": [ 4 | { 5 | "feature": { 6 | "id": 1804, 7 | "name": "hero", 8 | "type": "STANDARD" 9 | }, 10 | "enabled": true, 11 | "feature_state_value": "https://s3-us-west-2.amazonaws.com/com.uppercut.hero-images/assets/0466/comps/466_03314.jpg" 12 | }, 13 | { 14 | "feature": { 15 | "id": 6149, 16 | "name": "font_size", 17 | "type": "STANDARD" 18 | }, 19 | "enabled": true, 20 | "feature_state_value": 16 21 | }, 22 | { 23 | "feature": { 24 | "id": 80317, 25 | "name": "json_value", 26 | "type": "STANDARD" 27 | }, 28 | "enabled": true, 29 | "feature_state_value": "{\"title\":\"Hello World\"}" 30 | }, 31 | { 32 | "feature": { 33 | "id": 80318, 34 | "name": "number_value", 35 | "type": "STANDARD" 36 | }, 37 | "enabled": true, 38 | "feature_state_value": 1 39 | }, 40 | { 41 | "feature": { 42 | "id": 80319, 43 | "name": "off_value", 44 | "type": "STANDARD" 45 | }, 46 | "enabled": false, 47 | "feature_state_value": null 48 | } 49 | ], 50 | "traits": [] 51 | } -------------------------------------------------------------------------------- /test/data/identities_test_transient_identity.json: -------------------------------------------------------------------------------- 1 | { 2 | "identifier": "test_transient_identity", 3 | "flags": [ 4 | { 5 | "feature": { 6 | "id": 1804, 7 | "name": "hero", 8 | "type": "STANDARD" 9 | }, 10 | "enabled": true, 11 | "feature_state_value": "https://s3-us-west-2.amazonaws.com/com.uppercut.hero-images/assets/0466/comps/466_03314.jpg" 12 | }, 13 | { 14 | "feature": { 15 | "id": 6149, 16 | "name": "font_size", 17 | "type": "STANDARD" 18 | }, 19 | "enabled": true, 20 | "feature_state_value": 16 21 | }, 22 | { 23 | "feature": { 24 | "id": 80317, 25 | "name": "json_value", 26 | "type": "STANDARD" 27 | }, 28 | "enabled": true, 29 | "feature_state_value": "{\"title\":\"Hello World\"}" 30 | }, 31 | { 32 | "feature": { 33 | "id": 80318, 34 | "name": "number_value", 35 | "type": "STANDARD" 36 | }, 37 | "enabled": true, 38 | "feature_state_value": 1 39 | }, 40 | { 41 | "feature": { 42 | "id": 80319, 43 | "name": "off_value", 44 | "type": "STANDARD" 45 | }, 46 | "enabled": false, 47 | "feature_state_value": null 48 | } 49 | ], 50 | "traits": [] 51 | } -------------------------------------------------------------------------------- /lib/flagsmith/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flagsmith", 3 | "version": "10.0.0", 4 | "description": "Feature flagging to support continuous development", 5 | "main": "./index.js", 6 | "module": "./index.mjs", 7 | "browser": "./index.js", 8 | "types": "./index.d.ts", 9 | "exports": { 10 | "./types": { 11 | "import": "./types.d.ts", 12 | "require": "./types.d.ts", 13 | "browser": "./types.d.ts" 14 | }, 15 | ".": { 16 | "import": "./index.mjs", 17 | "require": "./index.js", 18 | "browser": "./index.js", 19 | "types": "./index.d.ts" 20 | }, 21 | "./isomorphic": { 22 | "import": "./isomorphic.mjs", 23 | "require": "./isomorphic.js", 24 | "browser": "./isomorphic.js", 25 | "types": "./isomorphic.d.ts" 26 | }, 27 | "./react": { 28 | "import": "./react.mjs", 29 | "require": "./react.js", 30 | "browser": "./react.js", 31 | "types": "./react.d.ts" 32 | }, 33 | "./next-middleware": { 34 | "import": "./next-middleware.mjs", 35 | "require": "./next-middleware.js", 36 | "browser": "./next-middleware.js", 37 | "types": "./next-middleware.d.ts" 38 | } 39 | }, 40 | "repository": { 41 | "type": "git", 42 | "url": "https://github.com/Flagsmith/flagsmith-js-client" 43 | }, 44 | "keywords": [ 45 | "feature flagger", 46 | "continuous deployment" 47 | ], 48 | "author": "Flagsmith", 49 | "license": "BSD-3-Clause", 50 | "bugs": { 51 | "url": "https://github.com/Flagsmith/flagsmith-js-client/issues" 52 | }, 53 | "homepage": "https://flagsmith.com" 54 | } 55 | -------------------------------------------------------------------------------- /test/data/identities_test_identity_a.json: -------------------------------------------------------------------------------- 1 | { 2 | "identifier": "test_identity_a", 3 | "flags": [ 4 | { 5 | "feature": { 6 | "id": 1804, 7 | "name": "hero", 8 | "type": "STANDARD" 9 | }, 10 | "enabled": true, 11 | "feature_state_value": "https://s3-us-west-2.amazonaws.com/com.uppercut.hero-images/assets/0466/comps/466_03314.jpg" 12 | }, 13 | { 14 | "feature": { 15 | "id": 6149, 16 | "name": "font_size", 17 | "type": "STANDARD" 18 | }, 19 | "enabled": true, 20 | "feature_state_value": 16 21 | }, 22 | { 23 | "feature": { 24 | "id": 80317, 25 | "name": "json_value", 26 | "type": "STANDARD" 27 | }, 28 | "enabled": true, 29 | "feature_state_value": "{\"title\":\"Hello World\"}" 30 | }, 31 | { 32 | "feature": { 33 | "id": 80318, 34 | "name": "number_value", 35 | "type": "STANDARD" 36 | }, 37 | "enabled": true, 38 | "feature_state_value": 1 39 | }, 40 | { 41 | "feature": { 42 | "id": 80319, 43 | "name": "off_value", 44 | "type": "STANDARD" 45 | }, 46 | "enabled": false, 47 | "feature_state_value": null 48 | } 49 | ], 50 | "traits": [ 51 | { 52 | "trait_key": "a", 53 | "trait_value": "example" 54 | } 55 | ] 56 | } -------------------------------------------------------------------------------- /utils/angular-fetch.ts: -------------------------------------------------------------------------------- 1 | export default (angularHttpClient: any) => (url: string, params: { 2 | headers: Record, 3 | method: "GET" | "POST" | "PUT", 4 | body?: string 5 | }) => { 6 | const { headers, method, body } = params; 7 | const options = { headers, observe: 'response', responseType: 'text' }; 8 | 9 | const buildResponse = (response: any, ok: boolean) => { 10 | const { status, headers, body, error, message } = response; 11 | return { 12 | status: status ?? (ok ? 200 : 500), 13 | ok, 14 | headers: { get: (name: string) => headers?.get?.(name) ?? null }, 15 | text: () => { 16 | const value = body ?? error ?? message ?? ''; 17 | return Promise.resolve(typeof value !== 'string' ? JSON.stringify(value) : value); 18 | }, 19 | }; 20 | }; 21 | 22 | return new Promise((resolve) => { 23 | const onNext = (res: any) => resolve(buildResponse(res, res.status ? res.status >= 200 && res.status < 300 : true)); 24 | const onError = (err: any) => resolve(buildResponse(err, false)); 25 | switch (method) { 26 | case "GET": 27 | return angularHttpClient.get(url, options).subscribe(onNext, onError); 28 | case "POST": 29 | return angularHttpClient.post(url, body ?? '', options).subscribe(onNext, onError); 30 | case "PUT": 31 | return angularHttpClient.post(url, body ?? '', options).subscribe(onNext, onError); 32 | default: 33 | return onError({ status: 405, message: `Unsupported method: ${method}` }); 34 | } 35 | }); 36 | }; 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Flagsmith Javascript Client 4 | 5 | [![npm version](https://badge.fury.io/js/flagsmith.svg)](https://badge.fury.io/js/flagsmith) 6 | [![](https://data.jsdelivr.com/v1/package/npm/flagsmith/badge)](https://www.jsdelivr.com/package/npm/flagsmith) 7 | 8 | The SDK clients for web and React Native for [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/javascript/](https://docs.flagsmith.com/clients/javascript/) 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 to us. 17 | 18 | ### Development 19 | 20 | 1. Install Node >= v14.4 21 | 1. Clone the repo 22 | 1. Install dependencies: `npm install` 23 | 1. Generate files: `npm run build` 24 | 1. Make your code changes 25 | 1. Ensure the project builds with `npm run build` and all tests are passing with `npm run test` before submitting a pull request 26 | 27 | ## Getting Help 28 | 29 | 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. 30 | 31 | ## Get in touch 32 | 33 | If you have any questions about our projects you can email support@flagsmith.com. 34 | 35 | ## Useful links 36 | 37 | [Website](https://www.flagsmith.com/) 38 | 39 | [Documentation](https://docs.flagsmith.com/) 40 | -------------------------------------------------------------------------------- /test/data/identities_test_identity.json: -------------------------------------------------------------------------------- 1 | { 2 | "identifier": "test_identity", 3 | "flags": [ 4 | { 5 | "feature": { 6 | "id": 1804, 7 | "name": "hero", 8 | "type": "STANDARD" 9 | }, 10 | "enabled": true, 11 | "feature_state_value": "https://s3-us-west-2.amazonaws.com/com.uppercut.hero-images/assets/0466/comps/466_03314.jpg" 12 | }, 13 | { 14 | "feature": { 15 | "id": 6149, 16 | "name": "font_size", 17 | "type": "STANDARD" 18 | }, 19 | "enabled": true, 20 | "feature_state_value": 16 21 | }, 22 | { 23 | "feature": { 24 | "id": 80317, 25 | "name": "json_value", 26 | "type": "STANDARD" 27 | }, 28 | "enabled": true, 29 | "feature_state_value": "{\"title\":\"Hello World\"}" 30 | }, 31 | { 32 | "feature": { 33 | "id": 80318, 34 | "name": "number_value", 35 | "type": "STANDARD" 36 | }, 37 | "enabled": true, 38 | "feature_state_value": 1 39 | }, 40 | { 41 | "feature": { 42 | "id": 80319, 43 | "name": "off_value", 44 | "type": "STANDARD" 45 | }, 46 | "enabled": false, 47 | "feature_state_value": null 48 | } 49 | ], 50 | "traits": [ 51 | { 52 | "trait_key": "number_trait", 53 | "trait_value": 1 54 | }, 55 | { 56 | "trait_key": "string_trait", 57 | "trait_value": "Example" 58 | } 59 | ] 60 | } -------------------------------------------------------------------------------- /test/data/identities_test_identity_with_traits.json: -------------------------------------------------------------------------------- 1 | { 2 | "identifier": "test_identity_with_traits", 3 | "flags": [ 4 | { 5 | "feature": { 6 | "id": 1804, 7 | "name": "hero", 8 | "type": "STANDARD" 9 | }, 10 | "enabled": true, 11 | "feature_state_value": "https://s3-us-west-2.amazonaws.com/com.uppercut.hero-images/assets/0466/comps/466_03314.jpg" 12 | }, 13 | { 14 | "feature": { 15 | "id": 6149, 16 | "name": "font_size", 17 | "type": "STANDARD" 18 | }, 19 | "enabled": true, 20 | "feature_state_value": 16 21 | }, 22 | { 23 | "feature": { 24 | "id": 80317, 25 | "name": "json_value", 26 | "type": "STANDARD" 27 | }, 28 | "enabled": true, 29 | "feature_state_value": "{\"title\":\"Hello World\"}" 30 | }, 31 | { 32 | "feature": { 33 | "id": 80318, 34 | "name": "number_value", 35 | "type": "STANDARD" 36 | }, 37 | "enabled": true, 38 | "feature_state_value": 1 39 | }, 40 | { 41 | "feature": { 42 | "id": 80319, 43 | "name": "off_value", 44 | "type": "STANDARD" 45 | }, 46 | "enabled": false, 47 | "feature_state_value": null 48 | } 49 | ], 50 | "traits": [ 51 | { 52 | "trait_key": "number_trait", 53 | "trait_value": 1 54 | }, 55 | { 56 | "trait_key": "string_trait", 57 | "trait_value": "Example" 58 | } 59 | ] 60 | } -------------------------------------------------------------------------------- /test/data/identities_test_identity_with_transient_traits.json: -------------------------------------------------------------------------------- 1 | { 2 | "identifier": "test_identity_with_transient_traits", 3 | "flags": [ 4 | { 5 | "feature": { 6 | "id": 1804, 7 | "name": "hero", 8 | "type": "STANDARD" 9 | }, 10 | "enabled": true, 11 | "feature_state_value": "https://s3-us-west-2.amazonaws.com/com.uppercut.hero-images/assets/0466/comps/466_03314.jpg" 12 | }, 13 | { 14 | "feature": { 15 | "id": 6149, 16 | "name": "font_size", 17 | "type": "STANDARD" 18 | }, 19 | "enabled": true, 20 | "feature_state_value": 16 21 | }, 22 | { 23 | "feature": { 24 | "id": 80317, 25 | "name": "json_value", 26 | "type": "STANDARD" 27 | }, 28 | "enabled": true, 29 | "feature_state_value": "{\"title\":\"Hello World\"}" 30 | }, 31 | { 32 | "feature": { 33 | "id": 80318, 34 | "name": "number_value", 35 | "type": "STANDARD" 36 | }, 37 | "enabled": true, 38 | "feature_state_value": 1 39 | }, 40 | { 41 | "feature": { 42 | "id": 80319, 43 | "name": "off_value", 44 | "type": "STANDARD" 45 | }, 46 | "enabled": false, 47 | "feature_state_value": null 48 | } 49 | ], 50 | "traits": [ 51 | { 52 | "trait_key": "number_trait", 53 | "trait_value": 1 54 | }, 55 | { 56 | "trait_key": "string_trait", 57 | "trait_value": "Example" 58 | }, 59 | { 60 | "trait_key": "transient_trait", 61 | "trait_value": "Example", 62 | "transient": true 63 | } 64 | ] 65 | } -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from '@rollup/plugin-node-resolve'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import typescript from '@rollup/plugin-typescript'; 4 | import { terser } from 'rollup-plugin-terser'; 5 | import peerDepsExternal from 'rollup-plugin-peer-deps-external'; 6 | import path from 'path'; 7 | 8 | const externalDependencies = ["react", "react-dom", "react-native"]; 9 | 10 | const createPlugins = (exclude) => [ 11 | peerDepsExternal(), 12 | resolve(), 13 | commonjs(), 14 | typescript({ tsconfig: "./tsconfig.json", exclude }), 15 | terser({ 16 | format: { 17 | comments: false, 18 | }, 19 | }), 20 | ]; 21 | const sourcemapPathTransform = (relativeSourcePath) => { 22 | if(relativeSourcePath.includes("node_modules")) { 23 | return relativeSourcePath 24 | } 25 | return relativeSourcePath.replace("../../../", "./src/"); 26 | } 27 | 28 | const generateConfig = (input, outputDir, name, exclude = []) => ({ 29 | input, 30 | output: [ 31 | { file: path.join(outputDir, `${name}.js`), format: "umd", name, sourcemap: true,sourcemapPathTransform }, 32 | { file: path.join(outputDir, `${name}.mjs`), format: "es", sourcemap: true, sourcemapPathTransform }, 33 | ], 34 | plugins: createPlugins(exclude), 35 | external: externalDependencies, 36 | }); 37 | 38 | export default [ 39 | generateConfig('./index.ts', './lib/flagsmith', 'index', ['./react.tsx', './isomorphic.ts', './index.react-native.ts']), 40 | generateConfig('./isomorphic.ts', './lib/flagsmith', 'isomorphic', ['./react/index.ts', './index.react-native.ts']), 41 | generateConfig('./next-middleware.ts', './lib/flagsmith', 'next-middleware', ['./react.tsx', './index.react-native.ts']), 42 | generateConfig('./react.tsx', './lib/flagsmith', 'react', ['./index.ts', './types.ts', './isomorphic.ts', './index.react-native.ts']), 43 | generateConfig('./react.tsx', './lib/react-native-flagsmith', 'react', ['./index.ts', './types.ts', './isomorphic.ts', './index.react-native.ts']), 44 | generateConfig('./index.react-native.ts', './lib/react-native-flagsmith', 'index', ['./react/**', './isomorphic.ts', './index.react-native.ts']), 45 | ]; 46 | -------------------------------------------------------------------------------- /utils/emitter.ts: -------------------------------------------------------------------------------- 1 | interface EventCallback { 2 | id: string; 3 | fn: () => void; 4 | ctx?: any; 5 | } 6 | 7 | type EventName = string; 8 | 9 | interface EventMap { 10 | [eventName: string]: EventCallback[]; 11 | } 12 | 13 | class Emitter { 14 | private e: EventMap = {}; 15 | 16 | private generateCallbackId(): string { 17 | return Math.random().toString(36).substring(7); 18 | } 19 | 20 | on(name: EventName, callback: () => void, ctx?: any): () => this { 21 | const e = this.e || (this.e = {}); 22 | const id = this.generateCallbackId(); 23 | 24 | const listener = { 25 | id: id, 26 | fn: callback, 27 | ctx: ctx 28 | }; 29 | 30 | (e[name] || (e[name] = [])).push(listener); 31 | 32 | const offFunction = () => { 33 | this.off(name, id); 34 | }; 35 | 36 | return offFunction.bind(this) as () => this; 37 | } 38 | 39 | once( 40 | name: string, // EventName inlined as string 41 | callback: (...args: any[]) => void, 42 | ctx?: any 43 | ): () => this { 44 | const self = this; 45 | const id = this.generateCallbackId(); 46 | 47 | function listener(this: unknown, ...args: any[]) { 48 | self.off(name, id); 49 | callback.apply(ctx, args); 50 | } 51 | 52 | (listener as any)._ = callback; 53 | 54 | return this.on(name, listener, ctx) as () => this; 55 | } 56 | 57 | emit(name: EventName, ...data: any[]): this { 58 | const evtArr = ((this.e || (this.e = {}))[name] || []).slice(); 59 | const len = evtArr.length; 60 | 61 | for (let i = 0; i < len; i++) { 62 | evtArr[i].fn.apply(evtArr[i].ctx, data as any); 63 | } 64 | 65 | return this; 66 | } 67 | 68 | off(name: EventName, callbackOrId?: (() => void) | string, ctx?: any): this { 69 | const e = this.e || (this.e = {}); 70 | const evts = e[name]; 71 | const liveEvents: EventCallback[] = []; 72 | 73 | if (evts && callbackOrId) { 74 | for (let i = 0, len = evts.length; i < len; i++) { 75 | if ( 76 | (typeof callbackOrId === 'function' && evts[i].fn !== callbackOrId) || 77 | (typeof callbackOrId === 'string' && evts[i].id !== callbackOrId) 78 | ) { 79 | liveEvents.push(evts[i]); 80 | } 81 | } 82 | } 83 | 84 | (liveEvents.length ? e[name] = liveEvents : delete e[name]); 85 | 86 | return this; 87 | } 88 | } 89 | 90 | export default Emitter; 91 | -------------------------------------------------------------------------------- /release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "bootstrap-sha": "8c4aaf76fcffb2a1e432f57552a00b0c282e7601", 3 | "packages": { 4 | ".": { 5 | "release-type": "simple", 6 | "changelog-path": "CHANGELOG.md", 7 | "bump-minor-pre-major": false, 8 | "bump-patch-for-minor-pre-major": false, 9 | "draft": false, 10 | "prerelease": false, 11 | "include-component-in-tag": false, 12 | "extra-files": [ 13 | { 14 | "type": "json", 15 | "path": "lib/flagsmith/package.json", 16 | "jsonpath": "$.version" 17 | }, 18 | { 19 | "type": "json", 20 | "path": "lib/flagsmith/package-lock.json", 21 | "jsonpath": "$.version" 22 | }, 23 | { 24 | "type": "json", 25 | "path": "lib/flagsmith/package-lock.json", 26 | "jsonpath": "$.packages..version" 27 | }, 28 | { 29 | "type": "json", 30 | "path": "lib/react-native-flagsmith/package.json", 31 | "jsonpath": "$.version" 32 | }, 33 | { 34 | "type": "json", 35 | "path": "lib/react-native-flagsmith/package-lock.json", 36 | "jsonpath": "$.version" 37 | }, 38 | { 39 | "type": "json", 40 | "path": "lib/react-native-flagsmith/package-lock.json", 41 | "jsonpath": "$.packages..version" 42 | } 43 | ] 44 | } 45 | }, 46 | "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", 47 | "changelog-sections": [ 48 | { 49 | "type": "feat", 50 | "hidden": false, 51 | "section": "Features" 52 | }, 53 | { 54 | "type": "fix", 55 | "hidden": false, 56 | "section": "Bug Fixes" 57 | }, 58 | { 59 | "type": "ci", 60 | "hidden": false, 61 | "section": "CI" 62 | }, 63 | { 64 | "type": "docs", 65 | "hidden": false, 66 | "section": "Docs" 67 | }, 68 | { 69 | "type": "deps", 70 | "hidden": false, 71 | "section": "Dependency Updates" 72 | }, 73 | { 74 | "type": "perf", 75 | "hidden": false, 76 | "section": "Performance Improvements" 77 | }, 78 | { 79 | "type": "refactor", 80 | "hidden": false, 81 | "section": "Refactoring" 82 | }, 83 | { 84 | "type": "test", 85 | "hidden": false, 86 | "section": "Tests" 87 | }, 88 | { 89 | "type": "chore", 90 | "hidden": false, 91 | "section": "Other" 92 | } 93 | ] 94 | } 95 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flagsmith", 3 | "description": "Feature flagging to support continuous development", 4 | "main": "./flagsmith/index.js", 5 | "scripts": { 6 | "typecheck": "tsc", 7 | "prebuild": "node scripts/write-version.js", 8 | "build": "npm run prebuild && npm run bundle && npm run typecheck", 9 | "bundle": "rollup -c && node ./move-react.js", 10 | "deploy": "npm run build && npm test && cd ./lib/flagsmith/ && npm publish && cd ../../lib/react-native-flagsmith && npm publish", 11 | "deploy:beta": "npm run build && npm test && cd ./lib/flagsmith/ && npm publish --tag beta && cd ../../lib/react-native-flagsmith && npm publish --tag beta", 12 | "dev": "nodemon", 13 | "generatetypes": "curl https://raw.githubusercontent.com/Flagsmith/flagsmith/refs/heads/main/sdk/evaluation-context.json -o evaluation-context.json && npx quicktype -o evaluation-context.ts --src-lang schema --just-types --no-prefer-types --nice-property-names evaluation-context.json && rm evaluation-context.json", 14 | "postinstall": "patch-package", 15 | "prepublish": "npx in-publish && npm run build || echo", 16 | "test": "jest --env=jsdom", 17 | "checknodeversion": "npx ls-engines", 18 | "prepare": "husky", 19 | "precommit": "npm run typecheck && npm run prebuild" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/Flagsmith/flagsmith-js-client/" 24 | }, 25 | "keywords": [ 26 | "react native", 27 | "feature flagger", 28 | "continuous deployment" 29 | ], 30 | "author": "SSG", 31 | "license": "BSD-3-Clause", 32 | "bugs": { 33 | "url": "https://github.com/Flagsmith/flagsmith-js-client/issues" 34 | }, 35 | "homepage": "https://flagsmith.com", 36 | "devDependencies": { 37 | "@babel/preset-env": "^7.24.0", 38 | "@babel/preset-typescript": "^7.23.3", 39 | "@rollup/plugin-commonjs": "^21.0.2", 40 | "@rollup/plugin-node-resolve": "^13.3.0", 41 | "@rollup/plugin-replace": "^4.0.0", 42 | "@rollup/plugin-typescript": "^8.3.4", 43 | "@testing-library/react": "^14.2.1", 44 | "@types/jest": "^29.5.12", 45 | "@types/react": "^17.0.39", 46 | "@typescript-eslint/eslint-plugin": "5.4.0", 47 | "@typescript-eslint/parser": "5.4.0", 48 | "eslint": "^7.6.0", 49 | "eslint-config-prettier": "^8.3.0", 50 | "eslint-plugin-import": "2.27.5", 51 | "eslint-plugin-prettier": "^3.4.0", 52 | "eslint-plugin-react": "7.28.0", 53 | "eslint-plugin-react-hooks": "4.3.0", 54 | "fork-ts-checker-webpack-plugin": "^7.2.1", 55 | "husky": "^9.1.7", 56 | "in-publish": "^2.0.1", 57 | "jest": "^29.7.0", 58 | "jest-environment-jsdom": "^29.7.0", 59 | "nodemon": "^3.1.7", 60 | "patch-package": "^8.0.0", 61 | "quicktype": "^23.0.170", 62 | "react": "^18.2.0", 63 | "react-dom": "^18.2.0", 64 | "rollup": "^2.77.0", 65 | "rollup-plugin-dts": "^4.2.0", 66 | "rollup-plugin-peer-deps-external": "^2.2.4", 67 | "rollup-plugin-terser": "^7.0.2", 68 | "tiny-replace-files": "^1.0.2", 69 | "ts-jest": "^29.1.2", 70 | "ts-loader": "^9.2.7", 71 | "tslib": "^2.4.0", 72 | "typescript": "^4.6.2" 73 | }, 74 | "peerDependencies": { 75 | "react": "^17.0.2", 76 | "react-dom": "^17.0.2" 77 | }, 78 | "dependencies": { 79 | "@babel/preset-react": "^7.24.1", 80 | "encoding": "^0.1.12", 81 | "fast-deep-equal": "^3.1.3", 82 | "fs-extra": "^11.2.0", 83 | "isomorphic-unfetch": "^3.0.0", 84 | "react-native-sse": "^1.1.0", 85 | "reconnecting-eventsource": "^1.5.0" 86 | }, 87 | "types": "./index.d.ts", 88 | "engines": { 89 | "node": ">= 14.14" 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /move-react.js: -------------------------------------------------------------------------------- 1 | const path = require("path") 2 | const fs = require("fs") 3 | const fsExtra = require('fs-extra') 4 | 5 | // Copy source files to lib/flagsmith/src 6 | fs.copyFileSync(path.join(__dirname,"index.ts"),path.join(__dirname,"lib/flagsmith/src/index.ts")) 7 | fs.copyFileSync(path.join(__dirname,"flagsmith-core.ts"),path.join(__dirname,"lib/flagsmith/src/flagsmith-core.ts")) 8 | fs.copyFileSync(path.join(__dirname,"next-middleware.ts"),path.join(__dirname,"lib/flagsmith/src/next-middleware.ts")) 9 | fs.copyFileSync(path.join(__dirname,"isomorphic.ts"),path.join(__dirname,"lib/flagsmith/src/isomorphic.ts")) 10 | fs.copyFileSync(path.join(__dirname,"react.tsx"),path.join(__dirname,"lib/flagsmith/src/react.tsx")) 11 | fs.copyFileSync(path.join(__dirname,"react.tsx"),path.join(__dirname,"lib/react-native-flagsmith/src/react.tsx")) 12 | fs.copyFileSync(path.join(__dirname,"react.d.ts"),path.join(__dirname,"lib/react-native-flagsmith/react.d.ts")) 13 | fs.copyFileSync(path.join(__dirname,"react.d.ts"),path.join(__dirname,"lib/flagsmith/react.d.ts")) 14 | fs.copyFileSync(path.join(__dirname,"index.d.ts"),path.join(__dirname,"lib/react-native-flagsmith/index.d.ts")) 15 | fs.copyFileSync(path.join(__dirname,"index.d.ts"),path.join(__dirname,"lib/flagsmith/index.d.ts")) 16 | 17 | // Copy source files to lib/react-native-flagsmith/src 18 | fs.copyFileSync(path.join(__dirname,"index.react-native.ts"),path.join(__dirname,"lib/react-native-flagsmith/src/index.react-native.ts")) 19 | fs.copyFileSync(path.join(__dirname,"flagsmith-core.ts"),path.join(__dirname,"lib/react-native-flagsmith/src/flagsmith-core.ts")) 20 | 21 | const files= fs.readdirSync(path.join(__dirname, "lib/flagsmith")); 22 | files.forEach((fileName)=>{ 23 | console.log(fileName) 24 | if (fileName.endsWith(".d.ts")) { 25 | fs.copyFileSync(path.join(__dirname, "lib/flagsmith",fileName),path.join(__dirname, "lib/flagsmith/src",fileName)) 26 | } 27 | }) 28 | 29 | // copy types and evaluation-context 30 | fs.copyFileSync(path.join(__dirname,"types.d.ts"),path.join(__dirname,"lib/flagsmith/src/types.d.ts")) 31 | fs.copyFileSync(path.join(__dirname,"types.d.ts"),path.join(__dirname,"lib/react-native-flagsmith/src/types.d.ts")) 32 | fs.copyFileSync(path.join(__dirname,"types.d.ts"),path.join(__dirname,"lib/flagsmith/types.d.ts")) 33 | fs.copyFileSync(path.join(__dirname,"types.d.ts"),path.join(__dirname,"lib/react-native-flagsmith/types.d.ts")) 34 | fs.copyFileSync(path.join(__dirname,"evaluation-context.ts"),path.join(__dirname,"lib/flagsmith/evaluation-context.ts")) 35 | fs.copyFileSync(path.join(__dirname,"evaluation-context.ts"),path.join(__dirname,"lib/react-native-flagsmith/evaluation-context.ts")) 36 | 37 | 38 | try { 39 | fs.rmdirSync(path.join(__dirname,"lib/flagsmith/lib"), {recursive:true}) 40 | } catch (e){} 41 | try { 42 | fs.rmdirSync(path.join(__dirname,"lib/react-native-flagsmith/lib"), {recursive:true}) 43 | } catch (e){} 44 | 45 | try { 46 | fs.rmdirSync(path.join(__dirname,"lib/flagsmith/test"), {recursive:true}) 47 | } catch (e){} 48 | try { 49 | fs.rmdirSync(path.join(__dirname,"lib/react-native-flagsmith/test"), {recursive:true}) 50 | } catch (e){} 51 | 52 | 53 | 54 | function syncFolders(src, dest) { 55 | try { 56 | // Ensure the destination folder exists 57 | fsExtra.ensureDirSync(dest); 58 | 59 | const entries = fs.readdirSync(src, { withFileTypes: true }); 60 | 61 | for (const entry of entries) { 62 | const srcPath = path.join(src, entry.name); 63 | const destPath = path.join(dest, entry.name); 64 | 65 | if (entry.isFile()) { 66 | // Copy only files to the destination, overwriting existing files 67 | fs.copyFileSync(srcPath, destPath); 68 | } 69 | } 70 | 71 | console.log('Folders synchronized successfully!', src, dest); 72 | } catch (err) { 73 | console.error('Error synchronizing folders:', err); 74 | } 75 | } 76 | 77 | syncFolders(path.join(__dirname,'utils'),path.join(__dirname,'lib/flagsmith/src/utils')) 78 | syncFolders(path.join(__dirname,'utils'),path.join(__dirname,'lib/react-native-flagsmith/src/utils')) 79 | -------------------------------------------------------------------------------- /test/test-constants.ts: -------------------------------------------------------------------------------- 1 | import { IInitConfig, IState } from '../lib/flagsmith/types'; 2 | import MockAsyncStorage from './mocks/async-storage-mock'; 3 | import { createFlagsmithInstance } from '../lib/flagsmith'; 4 | import Mock = jest.Mock; 5 | import { promises as fs } from 'fs'; 6 | 7 | export const environmentID = 'QjgYur4LQTwe5HpvbvhpzK'; // Flagsmith Demo Projects 8 | export const FLAGSMITH_KEY = 'FLAGSMITH_DB' + "_" + environmentID; 9 | export const defaultState = { 10 | api: 'https://edge.api.flagsmith.com/api/v1/', 11 | evaluationContext: { 12 | environment: {apiKey: environmentID}, 13 | }, 14 | flags: { 15 | hero: { 16 | id: 1804, 17 | enabled: true, 18 | value: 'https://s3-us-west-2.amazonaws.com/com.uppercut.hero-images/assets/0466/comps/466_03314.jpg', 19 | }, 20 | font_size: { id: 6149, enabled: true, value: 16 }, 21 | json_value: { id: 80317, enabled: true, value: '{"title":"Hello World"}' }, 22 | number_value: { id: 80318, enabled: true, value: 1 }, 23 | off_value: { id: 80319, enabled: false, value: null }, 24 | }, 25 | }; 26 | 27 | export const testIdentity = 'test_identity' 28 | export const identityState = { 29 | api: 'https://edge.api.flagsmith.com/api/v1/', 30 | identity: testIdentity, 31 | evaluationContext: { 32 | environment: {apiKey: environmentID}, 33 | identity: { 34 | identifier: testIdentity, 35 | traits: { 36 | string_trait: {value: 'Example'}, 37 | number_trait: {value: 1}, 38 | } 39 | } 40 | }, 41 | flags: { 42 | hero: { 43 | id: 1804, 44 | enabled: true, 45 | value: 'https://s3-us-west-2.amazonaws.com/com.uppercut.hero-images/assets/0466/comps/466_03314.jpg' 46 | }, 47 | font_size: { id: 6149, enabled: true, value: 16 }, 48 | json_value: { id: 80317, enabled: true, value: '{"title":"Hello World"}' }, 49 | number_value: { id: 80318, enabled: true, value: 1 }, 50 | off_value: { id: 80319, enabled: false, value: null }, 51 | }, 52 | }; 53 | export const defaultStateAlt = { 54 | ...defaultState, 55 | flags: { 56 | 'example': { 57 | 'id': 1, 58 | 'enabled': true, 59 | 'value': 'a', 60 | }, 61 | }, 62 | }; 63 | 64 | export function getStateToCheck(_state: IState) { 65 | const state = { 66 | ..._state, 67 | identity: _state.evaluationContext?.identity?.identifier, 68 | }; 69 | delete state.evaluationEvent; 70 | // @ts-ignore internal property 71 | delete state.ts; 72 | return state; 73 | } 74 | 75 | export function getFlagsmith(config: Partial = {}) { 76 | const flagsmith = createFlagsmithInstance(); 77 | const AsyncStorage = new MockAsyncStorage(); 78 | const mockFetch = jest.fn(async (url, options) => { 79 | switch (url) { 80 | case 'https://edge.api.flagsmith.com/api/v1/flags/': 81 | return {status: 200, text: () => fs.readFile('./test/data/flags.json', 'utf8')} 82 | case 'https://edge.api.flagsmith.com/api/v1/identities/?identifier=' + testIdentity: 83 | return {status: 200, text: () => fs.readFile(`./test/data/identities_${testIdentity}.json`, 'utf8')} 84 | } 85 | 86 | throw new Error('Please mock the call to ' + url) 87 | }); 88 | 89 | //@ts-ignore, we want to test storage even though flagsmith thinks there is none 90 | flagsmith.canUseStorage = true; 91 | const initConfig: IInitConfig = { 92 | AsyncStorage, 93 | fetch: mockFetch, 94 | ...config, 95 | }; 96 | initConfig.evaluationContext = { 97 | environment: {apiKey: environmentID}, 98 | ...config?.evaluationContext, 99 | } 100 | return { flagsmith, initConfig, mockFetch, AsyncStorage }; 101 | } 102 | export const delay = (ms:number) => new Promise((resolve) => setTimeout(resolve, ms)); 103 | export function getMockFetchWithValue(mockFn:Mock, resolvedValue:object, ms=0) { 104 | mockFn.mockReturnValueOnce(delay(ms).then(()=>Promise.resolve({ 105 | status:200, 106 | text: () => Promise.resolve(JSON.stringify(resolvedValue)), // Mock json() to return the mock response 107 | json: () => Promise.resolve(resolvedValue), // Mock json() to return the mock response 108 | }))) 109 | } 110 | -------------------------------------------------------------------------------- /test/types.test.ts: -------------------------------------------------------------------------------- 1 | // Sample test 2 | import {getFlagsmith} from './test-constants'; 3 | import {IFlagsmith, IFlagsmithFeature} from '../types'; 4 | 5 | describe('Flagsmith Types', () => { 6 | 7 | // The following tests will fail to compile if any of the types fail / expect-error has no type issues 8 | // Therefore all of the following ts-expect-errors and eslint-disable-lines are intentional 9 | test('should allow supplying string generics to a flagsmith instance', async () => { 10 | const { flagsmith, } = getFlagsmith({ }); 11 | const typedFlagsmith = flagsmith as IFlagsmith<"flag1"|"flag2"> 12 | //@ts-expect-error - feature not defined 13 | typedFlagsmith.hasFeature("fail") 14 | //@ts-expect-error - feature not defined 15 | typedFlagsmith.getValue("fail") 16 | 17 | typedFlagsmith.hasFeature("flag1") 18 | typedFlagsmith.hasFeature("flag2") 19 | typedFlagsmith.getValue("flag1") 20 | typedFlagsmith.getValue("flag2") 21 | }); 22 | test('should allow supplying interface generics to a flagsmith instance', async () => { 23 | const { flagsmith } = getFlagsmith({}); 24 | const typedFlagsmith = flagsmith as IFlagsmith< 25 | { 26 | stringFlag: string 27 | numberFlag: number 28 | objectFlag: { first_name: string } 29 | }> 30 | typedFlagsmith.init({ 31 | environmentID: "test", 32 | defaultFlags: { 33 | stringFlag: { 34 | id: 1, 35 | enabled: true, 36 | value: "string_value" 37 | }, 38 | numberFlag: { 39 | id: 2, 40 | enabled: true, 41 | value: 123 42 | }, 43 | objectFlag: { 44 | id: 3, 45 | enabled: true, 46 | value: JSON.stringify({ first_name: "John" }) 47 | } 48 | }, 49 | onChange: (previousFlags) => { 50 | //eslint-disable-next-line @typescript-eslint/no-unused-vars 51 | const previousStringFlag = previousFlags?.stringFlag 52 | //eslint-disable-next-line @typescript-eslint/no-unused-vars 53 | const previousNumberFlag = previousFlags?.numberFlag 54 | //eslint-disable-next-line @typescript-eslint/no-unused-vars 55 | const previousObjectFlag = previousFlags?.objectFlag 56 | //@ts-expect-error - flag does not exist 57 | //eslint-disable-next-line @typescript-eslint/no-unused-vars 58 | const previousNonExistingFlag = previousFlags?.nonExistingFlag 59 | } 60 | }) 61 | 62 | //@ts-expect-error - feature not defined 63 | typedFlagsmith.hasFeature("fail") 64 | //@ts-expect-error - feature not defined 65 | typedFlagsmith.getValue("fail") 66 | 67 | typedFlagsmith.hasFeature("stringFlag") 68 | typedFlagsmith.hasFeature("numberFlag") 69 | typedFlagsmith.getValue("stringFlag") 70 | typedFlagsmith.getValue("numberFlag") 71 | 72 | const typedFlags = await typedFlagsmith.getAllFlags() 73 | //eslint-disable-next-line @typescript-eslint/no-unused-vars 74 | const asString = typedFlags.stringFlag 75 | //eslint-disable-next-line @typescript-eslint/no-unused-vars 76 | const asNumber = typedFlags.numberFlag 77 | //eslint-disable-next-line @typescript-eslint/no-unused-vars 78 | const asObject = typedFlags.objectFlag 79 | 80 | // @ts-expect-error - invalid does not exist on type 81 | //eslint-disable-next-line @typescript-eslint/no-unused-vars 82 | const asNonExisting = typedFlags.nonExistingFlag 83 | 84 | //eslint-disable-next-line @typescript-eslint/no-unused-vars 85 | const stringFlag: string | null = typedFlagsmith.getValue("stringFlag") 86 | //eslint-disable-next-line @typescript-eslint/no-unused-vars 87 | const numberFlag: number | null = typedFlagsmith.getValue("numberFlag") 88 | //eslint-disable-next-line @typescript-eslint/no-unused-vars 89 | const firstName: string | undefined = typedFlagsmith.getValue("objectFlag")?.first_name 90 | 91 | // @ts-expect-error - invalid does not exist on type 92 | //eslint-disable-next-line @typescript-eslint/no-unused-vars 93 | const invalidPointer: string = typedFlagsmith.getValue("objectFlag")?.invalid 94 | 95 | // @ts-expect-error - feature should be a number 96 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 97 | const incorrectNumberFlag: string = typedFlagsmith.getValue("numberFlag") 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /test/react-types.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {render} from '@testing-library/react'; 3 | import {FlagsmithProvider, useFlags, useFlagsmith} from '../lib/flagsmith/react'; 4 | import {getFlagsmith,} from './test-constants'; 5 | import { IFlagsmithFeature } from '../types'; 6 | 7 | 8 | describe.only('FlagsmithProvider', () => { 9 | it('should allow supplying interface generics to useFlagsmith', () => { 10 | const FlagsmithPage = ()=> { 11 | const typedFlagsmith = useFlagsmith< 12 | { 13 | stringFlag: string 14 | numberFlag: number 15 | objectFlag: { first_name: string } 16 | } 17 | >() 18 | //@ts-expect-error - feature not defined 19 | typedFlagsmith.hasFeature("fail") 20 | //@ts-expect-error - feature not defined 21 | typedFlagsmith.getValue("fail") 22 | 23 | typedFlagsmith.hasFeature("stringFlag") 24 | typedFlagsmith.hasFeature("numberFlag") 25 | typedFlagsmith.getValue("stringFlag") 26 | typedFlagsmith.getValue("numberFlag") 27 | 28 | //eslint-disable-next-line @typescript-eslint/no-unused-vars 29 | const stringFlag: string|null = typedFlagsmith.getValue("stringFlag") 30 | //eslint-disable-next-line @typescript-eslint/no-unused-vars 31 | const numberFlag: number|null = typedFlagsmith.getValue("numberFlag") 32 | //eslint-disable-next-line @typescript-eslint/no-unused-vars 33 | const firstName: string | undefined = typedFlagsmith.getValue("objectFlag")?.first_name 34 | 35 | // @ts-expect-error - invalid does not exist on type announcement 36 | //eslint-disable-next-line @typescript-eslint/no-unused-vars 37 | const invalidPointer: string | undefined = typedFlagsmith.getValue("objectFlag")?.invalid 38 | 39 | // @ts-expect-error - feature should be a number 40 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 41 | const incorrectNumberFlag: string = typedFlagsmith.getValue("numberFlag") 42 | 43 | return <> 44 | } 45 | const onChange = jest.fn(); 46 | const {flagsmith,initConfig, mockFetch} = getFlagsmith({onChange}) 47 | render( 48 | 49 | 50 | 51 | ); 52 | }); 53 | it('should allow supplying interface generics to useFlags', () => { 54 | const FlagsmithPage = ()=> { 55 | interface MyFeatureInterface { 56 | stringFlag: string 57 | numberFlag: number 58 | objectFlag: { first_name: string } 59 | } 60 | const typedFlagsmith = useFlags(["stringFlag", "numberFlag", "objectFlag"]) 61 | 62 | // @ts-expect-error - feature not defined 63 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 64 | const wrongTypedFlagsmith = useFlags(["non-existing-flag"]) 65 | //@ts-expect-error - feature not defined 66 | typedFlagsmith.fail?.enabled 67 | //@ts-expect-error - feature not defined 68 | typedFlagsmith.fail?.value 69 | 70 | typedFlagsmith.numberFlag 71 | typedFlagsmith.stringFlag 72 | typedFlagsmith.objectFlag 73 | 74 | //eslint-disable-next-line @typescript-eslint/no-unused-vars 75 | const stringFlag: string = typedFlagsmith.stringFlag?.value 76 | //eslint-disable-next-line @typescript-eslint/no-unused-vars 77 | const numberFlag: number = typedFlagsmith.numberFlag?.value 78 | //eslint-disable-next-line @typescript-eslint/no-unused-vars 79 | const firstName: string = typedFlagsmith.objectFlag?.value?.first_name 80 | 81 | // @ts-expect-error - invalid does not exist on type announcement 82 | //eslint-disable-next-line @typescript-eslint/no-unused-vars 83 | const invalidPointer: string = typedFlagsmith.objectFlag?.value?.invalid 84 | 85 | // @ts-expect-error - feature should be a number 86 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 87 | const incorrectNumberFlag: string = typedFlagsmith.numberFlag?.value 88 | 89 | return <> 90 | } 91 | const onChange = jest.fn(); 92 | const {flagsmith,initConfig, mockFetch} = getFlagsmith({onChange}) 93 | render( 94 | 95 | 96 | 97 | ); 98 | }); 99 | it('should allow supplying string type to useFlags', () => { 100 | const FlagsmithPage = ()=> { 101 | type StringTypes = "stringFlag" | "numberFlag" | "objectFlag" 102 | const typedFlagsmith = useFlags(["stringFlag", "numberFlag", "objectFlag"]) 103 | 104 | // @ts-expect-error - feature not defined 105 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 106 | const wrongTypedFlagsmith = useFlags(["non-existing-flag"]) 107 | 108 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 109 | const stringFlag: IFlagsmithFeature = typedFlagsmith.stringFlag 110 | //eslint-disable-next-line @typescript-eslint/no-unused-vars 111 | const numberFlag: IFlagsmithFeature = typedFlagsmith.numberFlag 112 | //eslint-disable-next-line @typescript-eslint/no-unused-vars 113 | const firstName: IFlagsmithFeature = typedFlagsmith.objectFlag 114 | 115 | return <> 116 | } 117 | const onChange = jest.fn(); 118 | const {flagsmith,initConfig, mockFetch} = getFlagsmith({onChange}) 119 | render( 120 | 121 | 122 | 123 | ); 124 | }); 125 | it('should compile if children prop is React.ReactNode', () => { 126 | const { flagsmith, initConfig } = getFlagsmith(); 127 | const children: React.ReactNode = "I am a ReactNode child"; 128 | const { container } = render( 129 | 130 | {children} 131 | 132 | ); 133 | 134 | expect(container.textContent).toBe(children); 135 | }); 136 | }); 137 | -------------------------------------------------------------------------------- /react.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' 2 | import Emitter from './utils/emitter' 3 | const events = new Emitter() 4 | 5 | import { IFlagsmith, IFlagsmithTrait, IFlagsmithFeature, IState } from './types' 6 | 7 | export const FlagsmithContext = createContext | null>(null) 8 | export type FlagsmithContextType = { 9 | flagsmith: IFlagsmith // The flagsmith instance 10 | options?: Parameters[0] // Initialisation options, if you do not provide this you will have to call init manually 11 | serverState?: IState 12 | children: React.ReactNode 13 | } 14 | 15 | export const FlagsmithProvider: FC = ({ flagsmith, options, serverState, children }) => { 16 | const firstRenderRef = useRef(true) 17 | if (flagsmith && !flagsmith?._trigger) { 18 | flagsmith._trigger = () => { 19 | // @ts-expect-error using internal function, consumers would never call this 20 | flagsmith?.log('React - trigger event received') 21 | events.emit('event') 22 | } 23 | } 24 | 25 | if (flagsmith && !flagsmith?._triggerLoadingState) { 26 | flagsmith._triggerLoadingState = () => { 27 | events.emit('loading_event') 28 | } 29 | } 30 | 31 | if (serverState && !flagsmith.initialised) { 32 | flagsmith.setState(serverState) 33 | } 34 | 35 | if (firstRenderRef.current) { 36 | firstRenderRef.current = false 37 | if (options) { 38 | flagsmith 39 | .init({ 40 | ...options, 41 | state: options.state || serverState, 42 | onChange: (...args) => { 43 | if (options.onChange) { 44 | options.onChange(...args) 45 | } 46 | }, 47 | }) 48 | .catch((error) => { 49 | // @ts-expect-error using internal function, consumers would never call this 50 | flagsmith?.log('React - Failed to initialize flagsmith', error) 51 | events.emit('event') 52 | }) 53 | } 54 | } 55 | return {children} 56 | } 57 | 58 | const useConstant = function (value: T): T { 59 | const ref = useRef(value) 60 | if (!ref.current) { 61 | ref.current = value 62 | } 63 | return ref.current 64 | } 65 | 66 | const flagsAsArray = (_flags: any): string[] => { 67 | if (typeof _flags === 'string') { 68 | return [_flags] 69 | } else if (typeof _flags === 'object') { 70 | // eslint-disable-next-line no-prototype-builtins 71 | if (_flags.hasOwnProperty('length')) { 72 | return _flags 73 | } 74 | } 75 | throw new Error('Flagsmith: please supply an array of strings or a single string of flag keys to useFlags') 76 | } 77 | 78 | const getRenderKey = (flagsmith: IFlagsmith, flags: string[], traits: string[] = []) => { 79 | return flags 80 | .map((k) => { 81 | return `${flagsmith.getValue(k)}${flagsmith.hasFeature(k)}` 82 | }) 83 | .concat(traits.map((t) => `${flagsmith.getTrait(t)}`)) 84 | .join(',') 85 | } 86 | 87 | export function useFlagsmithLoading() { 88 | const flagsmith = useContext(FlagsmithContext) 89 | const [loadingState, setLoadingState] = useState(flagsmith?.loadingState) 90 | const [subscribed, setSubscribed] = useState(false) 91 | const refSubscribed = useRef(subscribed) 92 | 93 | const eventListener = useCallback(() => { 94 | setLoadingState(flagsmith?.loadingState) 95 | }, [flagsmith]) 96 | if (!refSubscribed.current) { 97 | events.on('loading_event', eventListener) 98 | refSubscribed.current = true 99 | } 100 | 101 | useEffect(() => { 102 | if (!subscribed && flagsmith?.initialised) { 103 | events.on('loading_event', eventListener) 104 | setSubscribed(true) 105 | } 106 | return () => { 107 | if (subscribed) { 108 | events.off('loading_event', eventListener) 109 | } 110 | } 111 | }, [flagsmith, subscribed, eventListener]) 112 | 113 | return loadingState 114 | } 115 | 116 | type UseFlagsReturn, T extends string> = F extends string 117 | ? { 118 | [K in F]: IFlagsmithFeature 119 | } & { 120 | [K in T]: IFlagsmithTrait 121 | } 122 | : { 123 | [K in keyof F]: IFlagsmithFeature 124 | } & { 125 | [K in T]: IFlagsmithTrait 126 | } 127 | 128 | /** 129 | * Example usage: 130 | * 131 | * // A) Using string flags: 132 | * useFlags<"featureOne"|"featureTwo">(["featureOne", "featureTwo"]); 133 | * 134 | * // B) Using an object for F - this can be generated by our CLI: https://github.com/Flagsmith/flagsmith-cli : 135 | * interface MyFeatureInterface { 136 | * featureOne: string; 137 | * featureTwo: number; 138 | * } 139 | * useFlags(["featureOne", "featureTwo"]); 140 | */ 141 | export function useFlags, T extends string = string>( 142 | _flags: readonly (F | keyof F)[], 143 | _traits: readonly T[] = [] 144 | ) { 145 | const firstRender = useRef(true) 146 | const flags = useConstant(flagsAsArray(_flags)) 147 | const traits = useConstant(flagsAsArray(_traits)) 148 | const flagsmith = useContext(FlagsmithContext) 149 | const [renderRef, setRenderRef] = useState(getRenderKey(flagsmith as IFlagsmith, flags, traits)) 150 | const eventListener = useCallback(() => { 151 | const newRenderKey = getRenderKey(flagsmith as IFlagsmith, flags, traits) 152 | if (newRenderKey !== renderRef) { 153 | // @ts-expect-error using internal function, consumers would never call this 154 | flagsmith?.log('React - useFlags flags and traits have changed') 155 | setRenderRef(newRenderKey) 156 | } 157 | }, [renderRef]) 158 | const emitterRef = useRef(events.once('event', eventListener)) 159 | 160 | if (firstRender.current) { 161 | firstRender.current = false 162 | // @ts-expect-error using internal function, consumers would never call this 163 | flagsmith?.log('React - Initialising event listeners') 164 | } 165 | 166 | useEffect(() => { 167 | return () => { 168 | emitterRef.current?.() 169 | } 170 | }, []) 171 | 172 | const res = useMemo(() => { 173 | const res: any = {} 174 | flags 175 | .map((k) => { 176 | res[k] = { 177 | enabled: flagsmith!.hasFeature(k), 178 | value: flagsmith!.getValue(k), 179 | } 180 | }) 181 | .concat( 182 | traits?.map((v) => { 183 | res[v] = flagsmith!.getTrait(v) 184 | }) 185 | ) 186 | return res 187 | }, [renderRef]) 188 | 189 | return res as UseFlagsReturn 190 | } 191 | 192 | export function useFlagsmith, T extends string = string>() { 193 | const context = useContext(FlagsmithContext) 194 | 195 | if (!context) { 196 | throw new Error('useFlagsmith must be used with in a FlagsmithProvider') 197 | } 198 | 199 | return context as unknown as IFlagsmith 200 | } 201 | -------------------------------------------------------------------------------- /test/default-flags.test.ts: -------------------------------------------------------------------------------- 1 | import { defaultState, defaultStateAlt, FLAGSMITH_KEY, getFlagsmith, getStateToCheck } from './test-constants'; 2 | import { IFlags } from '../types'; 3 | 4 | describe('Default Flags', () => { 5 | 6 | beforeEach(() => { 7 | // Avoid mocks, but if you need to add them here 8 | }); 9 | test('should error and not hit the API when preventFetch is true without default flags', async () => { 10 | const onChange = jest.fn(); 11 | const { flagsmith, initConfig, AsyncStorage, mockFetch } = getFlagsmith({ onChange, preventFetch: true }); 12 | await expect(flagsmith.init(initConfig)).rejects.toThrow(Error); 13 | 14 | expect(AsyncStorage.getItem).toHaveBeenCalledTimes(1); 15 | expect(mockFetch).toHaveBeenCalledTimes(0); 16 | expect(onChange).toHaveBeenCalledTimes(1); 17 | expect(onChange).toHaveBeenCalledWith( 18 | null, 19 | { 'flagsChanged': null, 'isFromServer': false, 'traitsChanged': null }, 20 | { 21 | 'error': 'Wrong Flagsmith Configuration: preventFetch is true and no defaulFlags provided', 22 | 'isFetching': false, 23 | 'isLoading': false, 24 | 'source': 'DEFAULT_FLAGS', 25 | }, 26 | ); 27 | expect(getStateToCheck(flagsmith.getState())).toEqual({ 28 | ...defaultState, 29 | flags: {}, 30 | }); 31 | }); 32 | test('should return accurate changed flags', async () => { 33 | const onChange = jest.fn(); 34 | const defaultFlags: IFlags = { 35 | string_value:{id:1,enabled:true,value:"test"}, 36 | numeric_value:{id:2,enabled:true,value:1}, 37 | boolean_value:{id:3,enabled:true,value:true}, 38 | unchanged_string_value:{id:4,enabled:true,value:"test"}, 39 | unchanged_numeric_value:{id:5,enabled:true,value:1}, 40 | unchanged_boolean_value:{id:6,enabled:true,value:null}, 41 | } 42 | const itemsToRemove:IFlags = { 43 | string_value_remove:{id:7,enabled:true,value:"test"}, 44 | numeric_value_remove:{id:8,enabled:true,value:1}, 45 | boolean_value_remove:{id:9,enabled:true,value:true}, 46 | } 47 | const { flagsmith, initConfig, AsyncStorage, mockFetch } = getFlagsmith({ 48 | onChange, 49 | preventFetch: true, 50 | cacheFlags: true, 51 | defaultFlags: {...defaultFlags, ...itemsToRemove}, 52 | }); 53 | await AsyncStorage.setItem(FLAGSMITH_KEY, JSON.stringify({ 54 | ...defaultState, 55 | flags: { 56 | ...defaultFlags, 57 | string_value:{...defaultFlags.string_value, value:"test2"}, 58 | numeric_value:{...defaultFlags.numeric_value,value:2}, 59 | boolean_value:{...defaultFlags.boolean_value,enabled:false}, 60 | new_string_value:{...defaultFlags.string_value}, 61 | new_numeric_value:{...defaultFlags.numeric_value}, 62 | new_boolean_value:{...defaultFlags.boolean_value}, 63 | } 64 | })); 65 | await flagsmith.init(initConfig); 66 | 67 | expect(AsyncStorage.getItem).toHaveBeenCalledTimes(2); 68 | expect(mockFetch).toHaveBeenCalledTimes(0); 69 | expect(onChange).toHaveBeenCalledTimes(1); 70 | expect(onChange).toHaveBeenCalledWith( 71 | null, 72 | { 'flagsChanged': expect.arrayContaining([ 73 | "string_value", 74 | "numeric_value", 75 | "boolean_value", 76 | "new_string_value", 77 | "new_numeric_value", 78 | "new_boolean_value", 79 | ].concat(Object.keys(itemsToRemove))), 'isFromServer': false, 'traitsChanged': null }, 80 | { 81 | 'error': null, 82 | 'isFetching': false, 83 | 'isLoading': false, 84 | 'source': 'CACHE', 85 | }, 86 | ); 87 | }); 88 | test('should call onChange with default flags', async () => { 89 | const onChange = jest.fn(); 90 | const { flagsmith, initConfig, AsyncStorage, mockFetch } = getFlagsmith({ 91 | onChange, 92 | preventFetch: true, 93 | defaultFlags: defaultState.flags, 94 | }); 95 | await flagsmith.init(initConfig); 96 | 97 | expect(AsyncStorage.getItem).toHaveBeenCalledTimes(1); 98 | expect(mockFetch).toHaveBeenCalledTimes(0); 99 | expect(onChange).toHaveBeenCalledTimes(1); 100 | expect(onChange).toHaveBeenCalledWith( 101 | null, 102 | { 'flagsChanged': Object.keys(defaultState.flags), 'isFromServer': false, 'traitsChanged': null }, 103 | { 104 | 'error': null, 105 | 'isFetching': false, 106 | 'isLoading': false, 107 | 'source': 'DEFAULT_FLAGS', 108 | }, 109 | ); 110 | expect(getStateToCheck(flagsmith.getState())).toEqual({ 111 | ...defaultState, 112 | }); 113 | }); 114 | test('should call onChange with API flags', async () => { 115 | const onChange = jest.fn(); 116 | const { flagsmith, initConfig, AsyncStorage, mockFetch } = getFlagsmith({ 117 | onChange, 118 | defaultFlags: defaultStateAlt.flags, 119 | }); 120 | await flagsmith.init(initConfig); 121 | 122 | expect(AsyncStorage.getItem).toHaveBeenCalledTimes(1); 123 | expect(mockFetch).toHaveBeenCalledTimes(1); 124 | expect(onChange).toHaveBeenCalledTimes(1); 125 | expect(onChange).toHaveBeenCalledWith( 126 | defaultStateAlt.flags, 127 | { 'flagsChanged': Object.keys(defaultState.flags).concat(Object.keys(defaultStateAlt.flags)), 'isFromServer': true, 'traitsChanged': null }, 128 | { 129 | 'error': null, 130 | 'isFetching': false, 131 | 'isLoading': false, 132 | 'source': 'SERVER', 133 | }, 134 | ); 135 | expect(getStateToCheck(flagsmith.getState())).toEqual({ 136 | ...defaultState, 137 | }); 138 | }); 139 | test('should validate flags are unchanged when fetched', async () => { 140 | const onChange = jest.fn(); 141 | const { flagsmith, initConfig, AsyncStorage, mockFetch } = getFlagsmith({ 142 | onChange, 143 | preventFetch: true, 144 | defaultFlags: defaultState.flags, 145 | }); 146 | await flagsmith.init(initConfig); 147 | 148 | expect(AsyncStorage.getItem).toHaveBeenCalledTimes(1); 149 | expect(mockFetch).toHaveBeenCalledTimes(0); 150 | expect(onChange).toHaveBeenCalledTimes(1); 151 | expect(onChange).toHaveBeenCalledWith( 152 | null, 153 | { 'flagsChanged': Object.keys(defaultState.flags), 'isFromServer': false, 'traitsChanged': null }, 154 | { 155 | 'error': null, 156 | 'isFetching': false, 157 | 'isLoading': false, 158 | 'source': 'DEFAULT_FLAGS', 159 | }, 160 | ); 161 | expect(getStateToCheck(flagsmith.getState())).toEqual({ 162 | ...defaultState, 163 | }); 164 | await flagsmith.getFlags() 165 | expect(onChange).toHaveBeenCalledTimes(2); 166 | 167 | expect(onChange).toHaveBeenCalledWith( 168 | defaultState.flags, 169 | { 'flagsChanged': null, 'isFromServer': true, 'traitsChanged': null }, 170 | { 171 | 'error': null, 172 | 'isFetching': false, 173 | 'isLoading': false, 174 | 'source': 'SERVER', 175 | }, 176 | ); 177 | expect(getStateToCheck(flagsmith.getState())).toEqual({ 178 | ...defaultState, 179 | }); 180 | }); 181 | }); 182 | -------------------------------------------------------------------------------- /types.d.ts: -------------------------------------------------------------------------------- 1 | import { EvaluationContext, IdentityEvaluationContext, TraitEvaluationContext } from "./evaluation-context"; 2 | import { FlagSource } from "./flagsmith-core"; 3 | 4 | type IFlagsmithValue = T 5 | 6 | export type DynatraceObject = { 7 | "javaLongOrObject": Record, 8 | "date": Record, 9 | "shortString": Record, 10 | "javaDouble": Record, 11 | } 12 | 13 | export interface IFlagsmithFeature { 14 | id?: number; 15 | enabled: boolean; 16 | value: Value; 17 | } 18 | 19 | export declare type IFlagsmithTrait = IFlagsmithValue | TraitEvaluationContext; 20 | export declare type IFlags = Record; 21 | export declare type ITraits = Record; 22 | export declare type Traits = Record; 23 | 24 | export interface ClientIdentityEvaluationContext extends Omit { 25 | traits?: null | ITraits; 26 | } 27 | export interface ClientEvaluationContext extends Omit { 28 | identity?: null | ClientIdentityEvaluationContext; 29 | } 30 | 31 | export declare type GetValueOptions | object> = { 32 | skipAnalytics?: boolean 33 | json?: boolean 34 | fallback?: T 35 | } 36 | 37 | export declare type HasFeatureOptions = { 38 | skipAnalytics?: boolean 39 | fallback?: boolean 40 | } | boolean 41 | 42 | 43 | export declare type IIdentity = T; 44 | 45 | export interface IRetrieveInfo { 46 | isFromServer: boolean; 47 | flagsChanged: string[] | null; 48 | traitsChanged: string[] | null; 49 | } 50 | 51 | export interface IState { 52 | api: string; 53 | flags?: IFlags>; 54 | evaluationContext?: EvaluationContext; 55 | evaluationEvent?: Record> | null; 56 | ts?: number; 57 | identity?: string; 58 | } 59 | 60 | declare type ICacheOptions = { 61 | ttl?: number; 62 | skipAPI?: boolean; 63 | storageKey?: string; 64 | loadStale?: boolean; 65 | }; 66 | 67 | export declare type IDatadogRum = { 68 | trackTraits: boolean 69 | client: { 70 | setUser: (newUser: { 71 | [x: string]: unknown 72 | }) => void; 73 | getUser: () => { 74 | [x: string]: unknown 75 | }; 76 | [extraProps: string]: any 77 | } 78 | } 79 | 80 | 81 | export type ISentryClient = { 82 | getIntegrationByName(name:"FeatureFlags"): { 83 | addFeatureFlag(flag: string, enabled: boolean): void; 84 | } | undefined; 85 | } | undefined; 86 | 87 | 88 | export { FlagSource }; 89 | 90 | export declare type LoadingState = { 91 | error: Error | null, // Current error, resets on next attempt to fetch flags 92 | isFetching: boolean, // Whether there is a current request to fetch server flags 93 | isLoading: boolean, // Whether any flag data exists 94 | source: FlagSource // Indicates freshness of flags 95 | } 96 | 97 | export type OnChange = (previousFlags: IFlags> | null, params: IRetrieveInfo, loadingState:LoadingState) => void 98 | 99 | export type ApplicationMetadata = { 100 | name: string; 101 | version?: string; 102 | } 103 | 104 | export interface IInitConfig = string, T extends string = string> { 105 | AsyncStorage?: any; 106 | api?: string; 107 | evaluationContext?: ClientEvaluationContext; 108 | cacheFlags?: boolean; 109 | cacheOptions?: ICacheOptions; 110 | datadogRum?: IDatadogRum; 111 | sentryClient?: ISentryClient; 112 | defaultFlags?: IFlags>; 113 | fetch?: any; 114 | realtime?: boolean; 115 | eventSourceUrl?: string; 116 | enableAnalytics?: boolean; 117 | enableDynatrace?: boolean; 118 | enableLogs?: boolean; 119 | angularHttpClient?: any; 120 | environmentID?: string; 121 | headers?: object; 122 | identity?: IIdentity; 123 | traits?: ITraits; 124 | onChange?: OnChange>; 125 | onError?: (err: Error) => void; 126 | preventFetch?: boolean; 127 | state?: IState; 128 | _trigger?: () => void; 129 | _triggerLoadingState?: () => void; 130 | /** 131 | * Customer application metadata 132 | */ 133 | applicationMetadata?: ApplicationMetadata; 134 | } 135 | 136 | export interface IFlagsmithResponse { 137 | identifier?: string, 138 | traits?: { 139 | trait_key: string; 140 | trait_value: IFlagsmithValue; 141 | transient?: boolean; 142 | }[]; 143 | flags?: { 144 | enabled: boolean; 145 | feature_state_value: IFlagsmithValue; 146 | feature: { 147 | id: number; 148 | name: string; 149 | }; 150 | }[]; 151 | } 152 | type FKey = F extends string ? F : keyof F; 153 | type FValue> = F extends Record 154 | ? F[K] | null 155 | : IFlagsmithValue; 156 | 157 | /** 158 | * Example usage: 159 | * 160 | * // A) Using string flags: 161 | * import flagsmith from 'flagsmith' as IFlagsmith<"featureOne"|"featureTwo">; 162 | * 163 | * // B) Using an object for F - this can be generated by our CLI: https://github.com/Flagsmith/flagsmith-cli : 164 | * interface MyFeatureInterface { 165 | * featureOne: string; 166 | * featureTwo: number; 167 | * } 168 | * import flagsmith from 'flagsmith' as IFlagsmith; 169 | */ 170 | export interface IFlagsmith< 171 | F extends string | Record = string, 172 | T extends string = string 173 | > 174 | { 175 | /** 176 | * Initialise the sdk against a particular environment 177 | */ 178 | init: (config: IInitConfig, T>) => Promise; 179 | /** 180 | * Set evaluation context. Refresh the flags. 181 | */ 182 | setContext: (context: ClientEvaluationContext) => Promise; 183 | /** 184 | * Merge current evaluation context with the provided one. Refresh the flags. 185 | */ 186 | updateContext: (context: ClientEvaluationContext) => Promise; 187 | /** 188 | /** 189 | * Get current context. 190 | */ 191 | getContext: () => EvaluationContext; 192 | /** 193 | * Trigger a manual fetch of the environment features 194 | */ 195 | getFlags: () => Promise; 196 | /** 197 | * Returns the current flags 198 | */ 199 | getAllFlags: () => IFlags>; 200 | /** 201 | * Identify user, triggers a call to get flags if `flagsmith.init` has been called 202 | * */ 203 | identify: (userId: string, traits?: Record) => Promise; 204 | /** 205 | * Retrieves the current state of flagsmith 206 | */ 207 | getState: () => IState; 208 | /** 209 | * Set the current state of flagsmith 210 | */ 211 | setState: (state: IState) => void; 212 | /** 213 | * Clears the identity, triggers a call to getFlags 214 | */ 215 | logout: () => Promise; 216 | /** 217 | * Polls the flagsmith API, specify interval in ms 218 | */ 219 | startListening: (interval?: number) => void; 220 | /** 221 | * Stops polling 222 | */ 223 | stopListening: () => void; 224 | /** 225 | * Returns whether a feature is enabled, or a fallback value if it does not exist. 226 | * @param {HasFeatureOptions} [optionsOrSkipAnalytics=false] If `true`, will not track analytics for this flag 227 | * evaluation. Using a boolean for this parameter is deprecated - use `{ skipAnalytics: true }` instead. 228 | * @param [optionsOrSkipAnalytics.fallback=false] Returns this value if the feature does not exist. 229 | * @param [optionsOrSkipAnalytics.skipAnalytics=false] If `true`, do not track analytics for this feature evaluation. 230 | * @example 231 | * flagsmith.hasFeature("power_user_feature") 232 | * @example 233 | * flagsmith.hasFeature("enabled_by_default_feature", { fallback: true }) 234 | */ 235 | hasFeature: (key: FKey, optionsOrSkipAnalytics?: HasFeatureOptions) => boolean; 236 | 237 | /** 238 | * Returns the value of a feature, or a fallback value. 239 | * @param [options.json=false] Deserialise the feature value using `JSON.parse` and return the result or `options.fallback`. 240 | * @param [options.fallback=null] Return this value in any of these cases: 241 | * * The feature does not exist. 242 | * * The feature has no value. 243 | * * `options.json` is `true` and the feature's value is not valid JSON. 244 | * @param [options.skipAnalytics=false] If `true`, do not track analytics for this feature evaluation. 245 | * @param [skipAnalytics=false] Deprecated - use `options.skipAnalytics` instead. 246 | * @example 247 | * flagsmith.getValue("remote_config") // "{\"hello\":\"world\"}" 248 | * flagsmith.getValue("remote_config", { json: true }) // { hello: "world" } 249 | * @example 250 | * flagsmith.getValue("font_size") // "12px" 251 | * flagsmith.getValue("font_size", { json: true, fallback: "8px" }) // "8px" 252 | */ 253 | getValue>( 254 | key: K, 255 | options?: GetValueOptions>, 256 | skipAnalytics?: boolean 257 | ): IFlagsmithValue>; 258 | /** 259 | * Get the value of a particular trait for the identified user 260 | */ 261 | getTrait: (key: T) => IFlagsmithValue; 262 | /** 263 | * Get the values of all traits for the identified user 264 | */ 265 | getAllTraits: () => Record; 266 | /** 267 | * Set a specific trait for a given user id, triggers a call to get flags 268 | * */ 269 | setTrait: (key: T, value: IFlagsmithTrait) => Promise; 270 | /** 271 | * Set a key value set of traits for a given user, triggers a call to get flags 272 | */ 273 | setTraits: (traits: ITraits) => Promise; 274 | /** 275 | * The stored identity of the user 276 | */ 277 | identity?: IIdentity; 278 | /** 279 | * Whether the flagsmith SDK is initialised 280 | */ 281 | initialised?: boolean; 282 | 283 | /** 284 | * Returns ths current loading state 285 | */ 286 | loadingState?: LoadingState; 287 | 288 | /** 289 | * Used internally, this function will callback separately to onChange whenever flags are updated 290 | */ 291 | _trigger?: () => void; 292 | /** 293 | * Used internally, this function will trigger the useFlagsmithLoading hook when loading state changes 294 | */ 295 | _triggerLoadingState?: () => void; 296 | /** 297 | * Used internally, this is the cache options provided in flagsmith.init 298 | */ 299 | cacheOptions: { 300 | ttl: number; 301 | skipAPI: boolean; 302 | loadStale: boolean; 303 | }; 304 | /** 305 | * Used internally, this is the api provided in flagsmith.init, defaults to our production API 306 | */ 307 | api: string 308 | } 309 | 310 | export {}; 311 | -------------------------------------------------------------------------------- /test/react.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { render, screen, waitFor } from '@testing-library/react' 3 | import { FlagsmithProvider, useFlags, useFlagsmithLoading } from '../lib/flagsmith/react' 4 | import { 5 | defaultState, 6 | delay, 7 | FLAGSMITH_KEY, 8 | getFlagsmith, 9 | getMockFetchWithValue, 10 | identityState, 11 | testIdentity, 12 | } from './test-constants' 13 | import removeIds from './test-utils/remove-ids' 14 | 15 | const FlagsmithPage: FC> = () => { 16 | const flags = useFlags(Object.keys(defaultState.flags)) 17 | const loadingState = useFlagsmithLoading() 18 | return ( 19 | <> 20 |
{JSON.stringify(flags)}
21 |
{JSON.stringify(loadingState)}
22 | 23 | ) 24 | } 25 | 26 | export default FlagsmithPage 27 | describe('FlagsmithProvider', () => { 28 | it('renders without crashing', () => { 29 | const onChange = jest.fn() 30 | const { flagsmith, initConfig } = getFlagsmith({ onChange }) 31 | render( 32 | 33 | 34 | 35 | ) 36 | }) 37 | it('renders default state without any cache or default flags', () => { 38 | const onChange = jest.fn() 39 | const { flagsmith, initConfig, mockFetch } = getFlagsmith({ onChange }) 40 | render( 41 | 42 | 43 | 44 | ) 45 | 46 | expect(JSON.parse(screen.getByTestId('flags').innerHTML)).toEqual({ 47 | hero: { enabled: false, value: null }, 48 | font_size: { enabled: false, value: null }, 49 | json_value: { enabled: false, value: null }, 50 | number_value: { enabled: false, value: null }, 51 | off_value: { enabled: false, value: null }, 52 | }) 53 | expect(JSON.parse(screen.getByTestId('loading-state').innerHTML)).toEqual({ 54 | isLoading: true, 55 | isFetching: true, 56 | error: null, 57 | source: 'NONE', 58 | }) 59 | }) 60 | it('fetches and renders flags', async () => { 61 | const onChange = jest.fn() 62 | const { flagsmith, initConfig, mockFetch } = getFlagsmith({ onChange }) 63 | render( 64 | 65 | 66 | 67 | ) 68 | 69 | expect(mockFetch).toHaveBeenCalledTimes(1) 70 | await waitFor(() => { 71 | expect(JSON.parse(screen.getByTestId('loading-state').innerHTML)).toEqual({ 72 | isLoading: false, 73 | isFetching: false, 74 | error: null, 75 | source: 'SERVER', 76 | }) 77 | expect(JSON.parse(screen.getByTestId('flags').innerHTML)).toEqual(removeIds(defaultState.flags)) 78 | }) 79 | }) 80 | it('fetches and renders flags for an identified user', async () => { 81 | const onChange = jest.fn() 82 | const { flagsmith, initConfig, mockFetch } = getFlagsmith({ onChange, identity: testIdentity }) 83 | render( 84 | 85 | 86 | 87 | ) 88 | 89 | expect(mockFetch).toHaveBeenCalledTimes(1) 90 | await waitFor(() => { 91 | expect(JSON.parse(screen.getByTestId('loading-state').innerHTML)).toEqual({ 92 | isLoading: false, 93 | isFetching: false, 94 | error: null, 95 | source: 'SERVER', 96 | }) 97 | expect(JSON.parse(screen.getByTestId('flags').innerHTML)).toEqual(removeIds(identityState.flags)) 98 | }) 99 | }) 100 | it('renders cached flags', async () => { 101 | const onChange = jest.fn() 102 | const { flagsmith, initConfig, AsyncStorage } = getFlagsmith({ 103 | onChange, 104 | cacheFlags: true, 105 | preventFetch: true, 106 | defaultFlags: defaultState.flags, 107 | }) 108 | await AsyncStorage.setItem( 109 | FLAGSMITH_KEY, 110 | JSON.stringify({ 111 | ...defaultState, 112 | }) 113 | ) 114 | render( 115 | 116 | 117 | 118 | ) 119 | 120 | await waitFor(() => { 121 | expect(JSON.parse(screen.getByTestId('loading-state').innerHTML)).toEqual({ 122 | isLoading: false, 123 | isFetching: false, 124 | error: null, 125 | source: 'CACHE', 126 | }) 127 | expect(JSON.parse(screen.getByTestId('flags').innerHTML)).toEqual(removeIds(defaultState.flags)) 128 | }) 129 | }) 130 | 131 | it('renders cached flags by custom key', async () => { 132 | const customKey = 'custom_key' 133 | const onChange = jest.fn() 134 | const { flagsmith, initConfig, AsyncStorage } = getFlagsmith({ 135 | onChange, 136 | cacheFlags: true, 137 | preventFetch: true, 138 | defaultFlags: defaultState.flags, 139 | cacheOptions: { 140 | storageKey: customKey, 141 | }, 142 | }) 143 | await AsyncStorage.setItem( 144 | customKey, 145 | JSON.stringify({ 146 | ...defaultState, 147 | }) 148 | ) 149 | render( 150 | 151 | 152 | 153 | ) 154 | 155 | await waitFor(() => { 156 | expect(JSON.parse(screen.getByTestId('loading-state').innerHTML)).toEqual({ 157 | isLoading: false, 158 | isFetching: false, 159 | error: null, 160 | source: 'CACHE', 161 | }) 162 | expect(JSON.parse(screen.getByTestId('flags').innerHTML)).toEqual(removeIds(defaultState.flags)) 163 | }) 164 | }) 165 | 166 | it('renders default flags', async () => { 167 | const onChange = jest.fn() 168 | const { flagsmith, initConfig } = getFlagsmith({ 169 | onChange, 170 | preventFetch: true, 171 | defaultFlags: defaultState.flags, 172 | }) 173 | render( 174 | 175 | 176 | 177 | ) 178 | 179 | await waitFor(() => { 180 | expect(JSON.parse(screen.getByTestId('loading-state').innerHTML)).toEqual({ 181 | isLoading: false, 182 | isFetching: false, 183 | error: null, 184 | source: 'DEFAULT_FLAGS', 185 | }) 186 | expect(JSON.parse(screen.getByTestId('flags').innerHTML)).toEqual(removeIds(defaultState.flags)) 187 | }) 188 | }) 189 | it('ignores init response if identify gets called and resolves first', async () => { 190 | const onChange = jest.fn() 191 | const { flagsmith, initConfig, mockFetch } = getFlagsmith({ onChange }) 192 | getMockFetchWithValue( 193 | mockFetch, 194 | [ 195 | { 196 | enabled: false, 197 | feature_state_value: null, 198 | feature: { 199 | id: 1, 200 | name: 'hero', 201 | }, 202 | }, 203 | ], 204 | 300 205 | ) // resolves after flagsmith.identify, it should be ignored 206 | 207 | render( 208 | 209 | 210 | 211 | ) 212 | expect(mockFetch).toHaveBeenCalledTimes(1) 213 | getMockFetchWithValue( 214 | mockFetch, 215 | { 216 | flags: [ 217 | { 218 | enabled: true, 219 | feature_state_value: null, 220 | feature: { 221 | id: 1, 222 | name: 'hero', 223 | }, 224 | }, 225 | ], 226 | }, 227 | 0 228 | ) 229 | await flagsmith.identify(testIdentity) 230 | expect(mockFetch).toHaveBeenCalledTimes(2) 231 | await waitFor(() => { 232 | expect(JSON.parse(screen.getByTestId('flags').innerHTML).hero.enabled).toBe(true) 233 | }) 234 | await delay(500) 235 | expect(JSON.parse(screen.getByTestId('flags').innerHTML).hero.enabled).toBe(true) 236 | }) 237 | }) 238 | 239 | it('should not crash when server returns 500 error', async () => { 240 | const onChange = jest.fn() 241 | const onError = jest.fn() 242 | 243 | const { flagsmith, initConfig, mockFetch } = getFlagsmith({ 244 | onChange, 245 | onError, 246 | }) 247 | 248 | mockFetch.mockImplementationOnce(() => 249 | Promise.resolve({ 250 | status: 500, 251 | headers: { get: () => null }, 252 | text: () => Promise.resolve('API Response: 500'), 253 | }) 254 | ) 255 | 256 | expect(() => { 257 | render( 258 | 259 | 260 | 261 | ) 262 | }).not.toThrow() 263 | 264 | expect(mockFetch).toHaveBeenCalledTimes(1) 265 | 266 | await waitFor(() => { 267 | // Loading should complete with error 268 | const loadingState = JSON.parse(screen.getByTestId('loading-state').innerHTML) 269 | expect(loadingState.isLoading).toBe(false) 270 | expect(loadingState.isFetching).toBe(false) 271 | expect(loadingState.error).toBeTruthy() 272 | }) 273 | 274 | // onError callback should have been called 275 | expect(onError).toHaveBeenCalledTimes(1) 276 | }) 277 | 278 | it('should not throw unhandled promise rejection when server returns 500 error', async () => { 279 | const onChange = jest.fn() 280 | const onError = jest.fn() 281 | const unhandledRejectionHandler = jest.fn() 282 | const { flagsmith, initConfig, mockFetch } = getFlagsmith({ 283 | onChange, 284 | onError, 285 | }) 286 | window.addEventListener('unhandledrejection', unhandledRejectionHandler) 287 | 288 | mockFetch.mockImplementationOnce(() => 289 | Promise.resolve({ 290 | status: 500, 291 | headers: { get: () => null }, 292 | text: () => Promise.resolve('API Response: 500'), 293 | }) 294 | ) 295 | 296 | expect(() => { 297 | render( 298 | 299 | 300 | 301 | ) 302 | }).not.toThrow() 303 | 304 | expect(mockFetch).toHaveBeenCalledTimes(1) 305 | 306 | await waitFor(() => { 307 | // Loading should complete with error 308 | const loadingState = JSON.parse(screen.getByTestId('loading-state').innerHTML) 309 | expect(loadingState.isLoading).toBe(false) 310 | expect(loadingState.isFetching).toBe(false) 311 | expect(loadingState.error).toBeTruthy() 312 | }) 313 | 314 | // onError callback should have been called 315 | expect(onError).toHaveBeenCalledTimes(1) 316 | window.removeEventListener('unhandledrejection', unhandledRejectionHandler) 317 | }) 318 | -------------------------------------------------------------------------------- /patches/reconnecting-eventsource+1.5.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/reconnecting-eventsource/build/esm/reconnecting-eventsource.js b/node_modules/reconnecting-eventsource/build/esm/reconnecting-eventsource.js 2 | index b747477..6df1ef5 100644 3 | --- a/node_modules/reconnecting-eventsource/build/esm/reconnecting-eventsource.js 4 | +++ b/node_modules/reconnecting-eventsource/build/esm/reconnecting-eventsource.js 5 | @@ -99,7 +99,7 @@ var ReconnectingEventSource = /** @class */ (function () { 6 | this.url = url.toString(); 7 | this.readyState = this.CONNECTING; 8 | this.max_retry_time = 3000; 9 | - this.eventSourceClass = globalThis.EventSource; 10 | + this.eventSourceClass = globalThis.FlagsmithEventSource; 11 | if (this._configuration != null) { 12 | if (this._configuration.lastEventId) { 13 | this._lastEventId = this._configuration.lastEventId; 14 | @@ -168,19 +168,17 @@ var ReconnectingEventSource = /** @class */ (function () { 15 | this.onerror(event); 16 | } 17 | if (this._eventSource) { 18 | - if (this._eventSource.readyState === 2) { 19 | // reconnect with new object 20 | this._eventSource.close(); 21 | this._eventSource = null; 22 | // reconnect after random timeout < max_retry_time 23 | var timeout = Math.round(this.max_retry_time * Math.random()); 24 | this._timer = setTimeout(function () { return _this._start(); }, timeout); 25 | - } 26 | } 27 | }; 28 | ReconnectingEventSource.prototype._onevent = function (event) { 29 | var e_2, _a; 30 | - if (event instanceof MessageEvent) { 31 | + if (event && event.lastEventId) { 32 | this._lastEventId = event.lastEventId; 33 | } 34 | var listenersForType = this._listeners[event.type]; 35 | diff --git a/node_modules/reconnecting-eventsource/build/esnext/reconnecting-eventsource.js b/node_modules/reconnecting-eventsource/build/esnext/reconnecting-eventsource.js 36 | index 09f146e..2113a07 100644 37 | --- a/node_modules/reconnecting-eventsource/build/esnext/reconnecting-eventsource.js 38 | +++ b/node_modules/reconnecting-eventsource/build/esnext/reconnecting-eventsource.js 39 | @@ -44,7 +44,7 @@ export default class ReconnectingEventSource { 40 | this.url = url.toString(); 41 | this.readyState = this.CONNECTING; 42 | this.max_retry_time = 3000; 43 | - this.eventSourceClass = globalThis.EventSource; 44 | + this.eventSourceClass = globalThis.FlagsmithEventSource; 45 | if (this._configuration != null) { 46 | if (this._configuration.lastEventId) { 47 | this._lastEventId = this._configuration.lastEventId; 48 | @@ -100,7 +100,6 @@ export default class ReconnectingEventSource { 49 | this.onerror(event); 50 | } 51 | if (this._eventSource) { 52 | - if (this._eventSource.readyState === 2) { 53 | // reconnect with new object 54 | this._eventSource.close(); 55 | this._eventSource = null; 56 | @@ -108,10 +107,9 @@ export default class ReconnectingEventSource { 57 | const timeout = Math.round(this.max_retry_time * Math.random()); 58 | this._timer = setTimeout(() => this._start(), timeout); 59 | } 60 | - } 61 | } 62 | _onevent(event) { 63 | - if (event instanceof MessageEvent) { 64 | + if (event && event.lastEventId) { 65 | this._lastEventId = event.lastEventId; 66 | } 67 | const listenersForType = this._listeners[event.type]; 68 | diff --git a/node_modules/reconnecting-eventsource/build/src/reconnecting-eventsource.js b/node_modules/reconnecting-eventsource/build/src/reconnecting-eventsource.js 69 | index b3cf336..7efec8a 100644 70 | --- a/node_modules/reconnecting-eventsource/build/src/reconnecting-eventsource.js 71 | +++ b/node_modules/reconnecting-eventsource/build/src/reconnecting-eventsource.js 72 | @@ -48,7 +48,7 @@ class ReconnectingEventSource { 73 | this.url = url.toString(); 74 | this.readyState = this.CONNECTING; 75 | this.max_retry_time = 3000; 76 | - this.eventSourceClass = globalThis.EventSource; 77 | + this.eventSourceClass = globalThis.FlagsmithEventSource; 78 | if (this._configuration != null) { 79 | if (this._configuration.lastEventId) { 80 | this._lastEventId = this._configuration.lastEventId; 81 | @@ -104,18 +104,16 @@ class ReconnectingEventSource { 82 | this.onerror(event); 83 | } 84 | if (this._eventSource) { 85 | - if (this._eventSource.readyState === 2) { 86 | // reconnect with new object 87 | this._eventSource.close(); 88 | this._eventSource = null; 89 | // reconnect after random timeout < max_retry_time 90 | const timeout = Math.round(this.max_retry_time * Math.random()); 91 | this._timer = setTimeout(() => this._start(), timeout); 92 | - } 93 | } 94 | } 95 | _onevent(event) { 96 | - if (event instanceof MessageEvent) { 97 | + if (event && event.lastEventId) { 98 | this._lastEventId = event.lastEventId; 99 | } 100 | const listenersForType = this._listeners[event.type]; 101 | diff --git a/node_modules/reconnecting-eventsource/dist/ReconnectingEventSource.min.js b/node_modules/reconnecting-eventsource/dist/ReconnectingEventSource.min.js 102 | index 2065976..f1712d9 100644 103 | --- a/node_modules/reconnecting-eventsource/dist/ReconnectingEventSource.min.js 104 | +++ b/node_modules/reconnecting-eventsource/dist/ReconnectingEventSource.min.js 105 | @@ -1,2 +1,2 @@ 106 | -var _ReconnectingEventSource;(()=>{"use strict";var e={19:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.EventSourceNotAvailableError=void 0;class n extends Error{constructor(){super("EventSource not available.\nConsider loading an EventSource polyfill and making it available globally as EventSource, or passing one in as eventSourceClass to the ReconnectingEventSource constructor.")}}t.EventSourceNotAvailableError=n,t.default=class{constructor(e,t){if(this.CONNECTING=0,this.OPEN=1,this.CLOSED=2,this._configuration=null!=t?Object.assign({},t):void 0,this.withCredentials=!1,this._eventSource=null,this._lastEventId=null,this._timer=null,this._listeners={open:[],error:[],message:[]},this.url=e.toString(),this.readyState=this.CONNECTING,this.max_retry_time=3e3,this.eventSourceClass=globalThis.EventSource,null!=this._configuration&&(this._configuration.lastEventId&&(this._lastEventId=this._configuration.lastEventId,delete this._configuration.lastEventId),this._configuration.max_retry_time&&(this.max_retry_time=this._configuration.max_retry_time,delete this._configuration.max_retry_time),this._configuration.eventSourceClass&&(this.eventSourceClass=this._configuration.eventSourceClass,delete this._configuration.eventSourceClass)),null==this.eventSourceClass||"function"!=typeof this.eventSourceClass)throw new n;this._onevent_wrapped=e=>{this._onevent(e)},this._start()}dispatchEvent(e){throw new Error("Method not implemented.")}_start(){let e=this.url;this._lastEventId&&(-1===e.indexOf("?")?e+="?":e+="&",e+="lastEventId="+encodeURIComponent(this._lastEventId)),this._eventSource=new this.eventSourceClass(e,this._configuration),this._eventSource.onopen=e=>{this._onopen(e)},this._eventSource.onerror=e=>{this._onerror(e)},this._eventSource.onmessage=e=>{this.onmessage(e)};for(const e of Object.keys(this._listeners))this._eventSource.addEventListener(e,this._onevent_wrapped)}_onopen(e){0===this.readyState&&(this.readyState=1,this.onopen(e))}_onerror(e){if(1===this.readyState&&(this.readyState=0,this.onerror(e)),this._eventSource&&2===this._eventSource.readyState){this._eventSource.close(),this._eventSource=null;const e=Math.round(this.max_retry_time*Math.random());this._timer=setTimeout((()=>this._start()),e)}}_onevent(e){e instanceof MessageEvent&&(this._lastEventId=e.lastEventId);const t=this._listeners[e.type];if(null!=t)for(const n of[...t])n.call(this,e);"message"===e.type&&this.onmessage(e)}onopen(e){}onerror(e){}onmessage(e){}close(){this._timer&&(clearTimeout(this._timer),this._timer=null),this._eventSource&&(this._eventSource.close(),this._eventSource=null),this.readyState=2}addEventListener(e,t,n){null==this._listeners[e]&&(this._listeners[e]=[],null!=this._eventSource&&this._eventSource.addEventListener(e,this._onevent_wrapped));const s=this._listeners[e];s.includes(t)||(this._listeners[e]=[...s,t])}removeEventListener(e,t,n){const s=this._listeners[e];this._listeners[e]=s.filter((e=>e!==t))}}}},t={};function n(s){var i=t[s];if(void 0!==i)return i.exports;var r=t[s]={exports:{}};return e[s](r,r.exports,n),r.exports}var s={};(()=>{var e=s;Object.defineProperty(e,"__esModule",{value:!0});const t=n(19);Object.assign(window,{ReconnectingEventSource:t.default,EventSourceNotAvailableError:t.EventSourceNotAvailableError})})(),_ReconnectingEventSource=s})(); 107 | +var _ReconnectingEventSource;(()=>{"use strict";var e={19:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.EventSourceNotAvailableError=void 0;class n extends Error{constructor(){super("EventSource not available.\nConsider loading an EventSource polyfill and making it available globally as EventSource, or passing one in as eventSourceClass to the ReconnectingEventSource constructor.")}}t.EventSourceNotAvailableError=n,t.default=class{constructor(e,t){if(this.CONNECTING=0,this.OPEN=1,this.CLOSED=2,this._configuration=null!=t?Object.assign({},t):void 0,this.withCredentials=!1,this._eventSource=null,this._lastEventId=null,this._timer=null,this._listeners={open:[],error:[],message:[]},this.url=e.toString(),this.readyState=this.CONNECTING,this.max_retry_time=3e3,this.eventSourceClass=globalThis.EventSource,null!=this._configuration&&(this._configuration.lastEventId&&(this._lastEventId=this._configuration.lastEventId,delete this._configuration.lastEventId),this._configuration.max_retry_time&&(this.max_retry_time=this._configuration.max_retry_time,delete this._configuration.max_retry_time),this._configuration.eventSourceClass&&(this.eventSourceClass=this._configuration.eventSourceClass,delete this._configuration.eventSourceClass)),null==this.eventSourceClass||"function"!=typeof this.eventSourceClass)throw new n;this._onevent_wrapped=e=>{this._onevent(e)},this._start()}dispatchEvent(e){throw new Error("Method not implemented.")}_start(){let e=this.url;this._lastEventId&&(-1===e.indexOf("?")?e+="?":e+="&",e+="lastEventId="+encodeURIComponent(this._lastEventId)),this._eventSource=new this.eventSourceClass(e,this._configuration),this._eventSource.onopen=e=>{this._onopen(e)},this._eventSource.onerror=e=>{this._onerror(e)},this._eventSource.onmessage=e=>{this.onmessage(e)};for(const e of Object.keys(this._listeners))this._eventSource.addEventListener(e,this._onevent_wrapped)}_onopen(e){0===this.readyState&&(this.readyState=1,this.onopen(e))}_onerror(e){if(1===this.readyState&&(this.readyState=0,this.onerror(e)),this._eventSource&&2===this._eventSource.readyState){this._eventSource.close(),this._eventSource=null;const e=Math.round(this.max_retry_time*Math.random());this._timer=setTimeout((()=>this._start()),e)}}_onevent(e){e && e._lastEventId &&(this._lastEventId=e.lastEventId);const t=this._listeners[e.type];if(null!=t)for(const n of[...t])n.call(this,e);"message"===e.type&&this.onmessage(e)}onopen(e){}onerror(e){}onmessage(e){}close(){this._timer&&(clearTimeout(this._timer),this._timer=null),this._eventSource&&(this._eventSource.close(),this._eventSource=null),this.readyState=2}addEventListener(e,t,n){null==this._listeners[e]&&(this._listeners[e]=[],null!=this._eventSource&&this._eventSource.addEventListener(e,this._onevent_wrapped));const s=this._listeners[e];s.includes(t)||(this._listeners[e]=[...s,t])}removeEventListener(e,t,n){const s=this._listeners[e];this._listeners[e]=s.filter((e=>e!==t))}}}},t={};function n(s){var i=t[s];if(void 0!==i)return i.exports;var r=t[s]={exports:{}};return e[s](r,r.exports,n),r.exports}var s={};(()=>{var e=s;Object.defineProperty(e,"__esModule",{value:!0});const t=n(19);Object.assign(window,{ReconnectingEventSource:t.default,EventSourceNotAvailableError:t.EventSourceNotAvailableError})})(),_ReconnectingEventSource=s})(); 108 | //# sourceMappingURL=ReconnectingEventSource.min.js.map 109 | -------------------------------------------------------------------------------- /test/cache.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defaultState, 3 | defaultStateAlt, 4 | FLAGSMITH_KEY, 5 | getFlagsmith, 6 | getStateToCheck, 7 | identityState, 8 | testIdentity, 9 | } from './test-constants'; 10 | import SyncStorageMock from './mocks/sync-storage-mock'; 11 | import { promises as fs } from 'fs' 12 | 13 | describe('Cache', () => { 14 | 15 | beforeEach(() => { 16 | // Avoid mocks, but if you need to add them here 17 | }); 18 | test('should check cache but not call onChange when empty', async () => { 19 | const onChange = jest.fn(); 20 | const { flagsmith, initConfig, mockFetch } = getFlagsmith({ 21 | cacheFlags: true, 22 | onChange, 23 | }); 24 | expect(mockFetch).toHaveBeenCalledTimes(0); 25 | await flagsmith.init(initConfig); 26 | expect(mockFetch).toHaveBeenCalledTimes(1); 27 | expect(onChange).toHaveBeenCalledTimes(1); 28 | }); 29 | test('should set cache after init', async () => { 30 | const onChange = jest.fn(); 31 | const { flagsmith, initConfig, AsyncStorage, mockFetch } = getFlagsmith({ 32 | cacheFlags: true, 33 | onChange, 34 | }); 35 | await flagsmith.init(initConfig); 36 | const cache = await AsyncStorage.getItem(FLAGSMITH_KEY); 37 | expect(getStateToCheck(JSON.parse(`${cache}`))).toEqual(defaultState); 38 | }); 39 | test('should set cache after init with custom key', async () => { 40 | const onChange = jest.fn(); 41 | const customKey = 'custom_key'; 42 | const { flagsmith, initConfig, AsyncStorage, mockFetch } = getFlagsmith({ 43 | cacheFlags: true, 44 | cacheOptions: { 45 | storageKey: customKey, 46 | }, 47 | onChange, 48 | }); 49 | await flagsmith.init(initConfig); 50 | const cache = await AsyncStorage.getItem(customKey); 51 | expect(getStateToCheck(JSON.parse(`${cache}`))).toEqual(defaultState); 52 | }); 53 | test('should call onChange with cache then eventually with an API response', async () => { 54 | let onChangeCount = 0; 55 | const onChangePromise = new Promise((resolve) => { 56 | setInterval(() => { 57 | if (onChangeCount === 2) { 58 | resolve(null); 59 | } 60 | }, 100); 61 | }); 62 | const onChange = jest.fn(() => { 63 | onChangeCount += 1; 64 | }); 65 | 66 | const { flagsmith, initConfig, AsyncStorage, mockFetch } = getFlagsmith({ 67 | cacheFlags: true, 68 | onChange, 69 | }); 70 | await AsyncStorage.setItem(FLAGSMITH_KEY, JSON.stringify(defaultStateAlt)); 71 | await flagsmith.init(initConfig); 72 | 73 | // Flags retrieved from cache 74 | expect(getStateToCheck(flagsmith.getState())).toEqual(defaultStateAlt); 75 | expect(onChange).toHaveBeenCalledTimes(1); 76 | expect(onChange).toHaveBeenCalledWith(null, { 77 | 'flagsChanged': Object.keys(defaultStateAlt.flags), 78 | 'isFromServer': false, 79 | 'traitsChanged': null, 80 | }, { 'error': null, 'isFetching': true, 'isLoading': false, 'source': 'CACHE' }); 81 | expect(flagsmith.loadingState).toEqual({ error: null, isFetching: true, isLoading: false, source: 'CACHE' }); 82 | 83 | //Flags retrieved from API 84 | await onChangePromise; 85 | expect(onChange).toHaveBeenCalledTimes(2); 86 | expect(mockFetch).toHaveBeenCalledTimes(1); 87 | expect(flagsmith.loadingState).toEqual({ error: null, isFetching: false, isLoading: false, source: 'SERVER' }); 88 | expect(onChange).toHaveBeenCalledWith(defaultStateAlt.flags, { 89 | 'flagsChanged': Object.keys(defaultState.flags).concat(Object.keys(defaultStateAlt.flags)), 90 | 'isFromServer': true, 91 | 'traitsChanged': null, 92 | }, { 'error': null, 'isFetching': false, 'isLoading': false, 'source': 'SERVER' }); 93 | 94 | expect(getStateToCheck(flagsmith.getState())).toEqual(defaultState); 95 | }); 96 | test('should ignore cache with different identity', async () => { 97 | const onChange = jest.fn(); 98 | const { flagsmith, initConfig, AsyncStorage, mockFetch } = getFlagsmith({ 99 | cacheFlags: true, 100 | identity: testIdentity, 101 | onChange, 102 | }); 103 | await AsyncStorage.setItem(FLAGSMITH_KEY, JSON.stringify({ 104 | ...defaultStateAlt, 105 | identity: 'bad_identity', 106 | })); 107 | await flagsmith.init(initConfig); 108 | expect(onChange).toHaveBeenCalledTimes(1); 109 | expect(mockFetch).toHaveBeenCalledTimes(1); 110 | expect(getStateToCheck(flagsmith.getState())).toEqual(identityState); 111 | }); 112 | test('should ignore cache with expired ttl', async () => { 113 | const onChange = jest.fn(); 114 | const { flagsmith, initConfig, AsyncStorage, mockFetch } = getFlagsmith({ 115 | cacheFlags: true, 116 | onChange, 117 | cacheOptions: { ttl: 1 }, 118 | }); 119 | await AsyncStorage.setItem(FLAGSMITH_KEY, JSON.stringify({ 120 | ...defaultStateAlt, 121 | ts: new Date().valueOf() - 100, 122 | })); 123 | await flagsmith.init(initConfig); 124 | expect(onChange).toHaveBeenCalledTimes(1); 125 | expect(mockFetch).toHaveBeenCalledTimes(1); 126 | expect(getStateToCheck(flagsmith.getState())).toEqual({ 127 | ...defaultState, 128 | }); 129 | }); 130 | test('should not ignore cache with expired ttl and loadStale is set', async () => { 131 | const onChange = jest.fn(); 132 | const { flagsmith, initConfig, AsyncStorage, mockFetch } = getFlagsmith({ 133 | cacheFlags: true, 134 | onChange, 135 | cacheOptions: { ttl: 1, loadStale: true }, 136 | }); 137 | await AsyncStorage.setItem(FLAGSMITH_KEY, JSON.stringify({ 138 | ...defaultStateAlt, 139 | ts: new Date().valueOf() - 100, 140 | })); 141 | await flagsmith.init(initConfig); 142 | expect(onChange).toHaveBeenCalledTimes(1); 143 | expect(mockFetch).toHaveBeenCalledTimes(1); 144 | expect(getStateToCheck(flagsmith.getState())).toEqual({ 145 | ...defaultStateAlt, 146 | }); 147 | }); 148 | test('should not ignore cache with valid ttl', async () => { 149 | const onChange = jest.fn(); 150 | const { flagsmith, initConfig, AsyncStorage, mockFetch } = getFlagsmith({ 151 | cacheFlags: true, 152 | onChange, 153 | cacheOptions: { ttl: 1000 }, 154 | }); 155 | await AsyncStorage.setItem(FLAGSMITH_KEY, JSON.stringify({ 156 | ...defaultStateAlt, 157 | ts: new Date().valueOf(), 158 | })); 159 | await flagsmith.init(initConfig); 160 | expect(onChange).toHaveBeenCalledTimes(1); 161 | expect(mockFetch).toHaveBeenCalledTimes(1); 162 | expect(getStateToCheck(flagsmith.getState())).toEqual({ 163 | ...defaultStateAlt, 164 | }); 165 | }); 166 | test('should not ignore cache when setting is disabled', async () => { 167 | const onChange = jest.fn(); 168 | const { flagsmith, initConfig, AsyncStorage, mockFetch } = getFlagsmith({ 169 | cacheFlags: false, 170 | onChange, 171 | }); 172 | await AsyncStorage.setItem(FLAGSMITH_KEY, JSON.stringify({ 173 | ...defaultStateAlt, 174 | ts: new Date().valueOf(), 175 | })); 176 | await flagsmith.init(initConfig); 177 | expect(onChange).toHaveBeenCalledTimes(1); 178 | expect(mockFetch).toHaveBeenCalledTimes(1); 179 | expect(getStateToCheck(flagsmith.getState())).toEqual({ 180 | ...defaultState, 181 | }); 182 | }); 183 | test('should not get flags from API when skipAPI is set', async () => { 184 | const onChange = jest.fn(); 185 | const { flagsmith, initConfig, AsyncStorage, mockFetch } = getFlagsmith({ 186 | cacheFlags: true, 187 | onChange, 188 | cacheOptions: { ttl: 1000, skipAPI: true }, 189 | }); 190 | await AsyncStorage.setItem(FLAGSMITH_KEY, JSON.stringify({ 191 | ...defaultStateAlt, 192 | ts: new Date().valueOf(), 193 | })); 194 | await flagsmith.init(initConfig); 195 | expect(onChange).toHaveBeenCalledTimes(1); 196 | expect(mockFetch).toHaveBeenCalledTimes(0); 197 | expect(getStateToCheck(flagsmith.getState())).toEqual({ 198 | ...defaultStateAlt, 199 | }); 200 | }); 201 | test('should get flags from API when stale cache is loaded and skipAPI is set', async () => { 202 | const onChange = jest.fn(); 203 | const { flagsmith, initConfig, AsyncStorage, mockFetch } = getFlagsmith({ 204 | cacheFlags: true, 205 | onChange, 206 | cacheOptions: { ttl: 1, skipAPI: true, loadStale: true }, 207 | }); 208 | await AsyncStorage.setItem(FLAGSMITH_KEY, JSON.stringify({ 209 | ...defaultStateAlt, 210 | ts: new Date().valueOf() - 100, 211 | })); 212 | await flagsmith.init(initConfig); 213 | expect(onChange).toHaveBeenCalledTimes(1); 214 | expect(mockFetch).toHaveBeenCalledTimes(1); 215 | expect(getStateToCheck(flagsmith.getState())).toEqual({ 216 | ...defaultStateAlt, 217 | }); 218 | }); 219 | 220 | test('should validate flags are unchanged when fetched', async () => { 221 | const onChange = jest.fn(); 222 | const { flagsmith, initConfig, AsyncStorage, mockFetch } = getFlagsmith({ 223 | onChange, 224 | cacheFlags: true, 225 | preventFetch: true, 226 | }); 227 | await AsyncStorage.setItem(FLAGSMITH_KEY, JSON.stringify({ 228 | ...defaultState, 229 | })); 230 | await flagsmith.init(initConfig); 231 | 232 | expect(AsyncStorage.getItem).toHaveBeenCalledTimes(2); 233 | expect(mockFetch).toHaveBeenCalledTimes(0); 234 | expect(onChange).toHaveBeenCalledTimes(1); 235 | expect(onChange).toHaveBeenCalledWith( 236 | null, 237 | { 'flagsChanged': Object.keys(defaultState.flags), 'isFromServer': false, 'traitsChanged': null }, 238 | { 239 | 'error': null, 240 | 'isFetching': false, 241 | 'isLoading': false, 242 | 'source': 'CACHE', 243 | }, 244 | ); 245 | expect(getStateToCheck(flagsmith.getState())).toEqual({ 246 | ...defaultState, 247 | }); 248 | await flagsmith.getFlags(); 249 | expect(onChange).toHaveBeenCalledTimes(2); 250 | 251 | expect(onChange).toHaveBeenCalledWith( 252 | defaultState.flags, 253 | { 'flagsChanged': null, 'isFromServer': true, 'traitsChanged': null }, 254 | { 255 | 'error': null, 256 | 'isFetching': false, 257 | 'isLoading': false, 258 | 'source': 'SERVER', 259 | }, 260 | ); 261 | expect(getStateToCheck(flagsmith.getState())).toEqual({ 262 | ...defaultState, 263 | }); 264 | }); 265 | test('should validate flags are unchanged when fetched and default flags are provided', async () => { 266 | const onChange = jest.fn(); 267 | const { flagsmith, initConfig, AsyncStorage, mockFetch } = getFlagsmith({ 268 | onChange, 269 | cacheFlags: true, 270 | preventFetch: true, 271 | defaultFlags: defaultState.flags, 272 | }); 273 | await AsyncStorage.setItem(FLAGSMITH_KEY, JSON.stringify({ 274 | ...defaultState, 275 | })); 276 | await flagsmith.init(initConfig); 277 | 278 | expect(AsyncStorage.getItem).toHaveBeenCalledTimes(2); 279 | expect(mockFetch).toHaveBeenCalledTimes(0); 280 | expect(onChange).toHaveBeenCalledTimes(1); 281 | expect(onChange).toHaveBeenCalledWith( 282 | null, 283 | { 'flagsChanged': null, 'isFromServer': false, 'traitsChanged': null }, 284 | { 285 | 'error': null, 286 | 'isFetching': false, 287 | 'isLoading': false, 288 | 'source': 'CACHE', 289 | }, 290 | ); 291 | expect(getStateToCheck(flagsmith.getState())).toEqual({ 292 | ...defaultState, 293 | }); 294 | await flagsmith.getFlags(); 295 | expect(onChange).toHaveBeenCalledTimes(2); 296 | 297 | expect(onChange).toHaveBeenCalledWith( 298 | defaultState.flags, 299 | { 'flagsChanged': null, 'isFromServer': true, 'traitsChanged': null }, 300 | { 301 | 'error': null, 302 | 'isFetching': false, 303 | 'isLoading': false, 304 | 'source': 'SERVER', 305 | }, 306 | ); 307 | expect(getStateToCheck(flagsmith.getState())).toEqual({ 308 | ...defaultState, 309 | }); 310 | }); 311 | test('should synchronously use cache if implementation allows', async () => { 312 | const onChange = jest.fn(); 313 | const { flagsmith, initConfig, AsyncStorage } = getFlagsmith({ 314 | onChange, 315 | cacheFlags: true, 316 | preventFetch: true, 317 | }); 318 | const storage = new SyncStorageMock(); 319 | await storage.setItem(FLAGSMITH_KEY, JSON.stringify({ 320 | ...defaultState, 321 | })); 322 | flagsmith.init({ 323 | ...initConfig, 324 | AsyncStorage: storage, 325 | }); 326 | expect(onChange).toHaveBeenCalledWith( 327 | null, 328 | { 'flagsChanged': Object.keys(defaultState.flags), 'isFromServer': false, 'traitsChanged': null }, 329 | { 330 | 'error': null, 331 | 'isFetching': false, 332 | 'isLoading': false, 333 | 'source': 'CACHE', 334 | }, 335 | ); 336 | }); 337 | }); 338 | -------------------------------------------------------------------------------- /test/init.test.ts: -------------------------------------------------------------------------------- 1 | import { waitFor } from '@testing-library/react'; 2 | import {defaultState, FLAGSMITH_KEY, getFlagsmith, getStateToCheck, identityState} from './test-constants'; 3 | import { promises as fs } from 'fs'; 4 | import { SDK_VERSION } from '../utils/version' 5 | describe('Flagsmith.init', () => { 6 | beforeEach(() => { 7 | // Avoid mocks, but if you need to add them here 8 | }); 9 | test('should initialize with expected values', async () => { 10 | const onChange = jest.fn(); 11 | const { flagsmith, initConfig, AsyncStorage, mockFetch } = getFlagsmith({ onChange }); 12 | await flagsmith.init(initConfig); 13 | 14 | expect(flagsmith.getContext().environment?.apiKey).toBe(initConfig.evaluationContext?.environment?.apiKey); 15 | expect(flagsmith.api).toBe('https://edge.api.flagsmith.com/api/v1/'); // Assuming defaultAPI is globally defined 16 | expect(AsyncStorage.getItem).toHaveBeenCalledTimes(1); 17 | expect(mockFetch).toHaveBeenCalledTimes(1); 18 | expect(onChange).toHaveBeenCalledTimes(1); 19 | expect(onChange).toHaveBeenCalledWith( 20 | {}, 21 | { flagsChanged: Object.keys(defaultState.flags), isFromServer: true, traitsChanged: null }, 22 | { error: null, isFetching: false, isLoading: false, source: 'SERVER' }, 23 | ); 24 | expect(getStateToCheck(flagsmith.getState())).toEqual(defaultState); 25 | }); 26 | test('should initialize with identity', async () => { 27 | const onChange = jest.fn(); 28 | const { flagsmith, initConfig, AsyncStorage, mockFetch } = getFlagsmith({ 29 | onChange, 30 | identity: 'test_identity', 31 | }); 32 | await flagsmith.init(initConfig); 33 | 34 | expect(flagsmith.getContext().environment?.apiKey).toBe(initConfig.evaluationContext?.environment?.apiKey); 35 | expect(flagsmith.api).toBe('https://edge.api.flagsmith.com/api/v1/'); // Assuming defaultAPI is globally defined 36 | expect(AsyncStorage.getItem).toHaveBeenCalledTimes(1); 37 | expect(mockFetch).toHaveBeenCalledTimes(1); 38 | expect(onChange).toHaveBeenCalledTimes(1); 39 | expect(onChange).toHaveBeenCalledWith( 40 | {}, 41 | { 42 | flagsChanged: Object.keys(defaultState.flags), 43 | isFromServer: true, 44 | traitsChanged: expect.arrayContaining(Object.keys(identityState.evaluationContext.identity.traits)), 45 | }, 46 | { error: null, isFetching: false, isLoading: false, source: 'SERVER' }, 47 | ); 48 | expect(getStateToCheck(flagsmith.getState())).toEqual(identityState); 49 | }); 50 | test('should initialize with identity and traits', async () => { 51 | const onChange = jest.fn(); 52 | const testIdentityWithTraits = `test_identity_with_traits`; 53 | const { flagsmith, initConfig, AsyncStorage, mockFetch } = getFlagsmith({ 54 | onChange, 55 | identity: testIdentityWithTraits, 56 | traits: { number_trait: 1, string_trait: 'Example' }, 57 | }); 58 | mockFetch.mockResolvedValueOnce({ 59 | status: 200, 60 | text: () => fs.readFile(`./test/data/identities_${testIdentityWithTraits}.json`, 'utf8'), 61 | }); 62 | 63 | await flagsmith.init(initConfig); 64 | 65 | expect(flagsmith.getContext().environment?.apiKey).toBe(initConfig.evaluationContext?.environment?.apiKey); 66 | expect(flagsmith.api).toBe('https://edge.api.flagsmith.com/api/v1/'); // Assuming defaultAPI is globally defined 67 | expect(AsyncStorage.getItem).toHaveBeenCalledTimes(1); 68 | expect(mockFetch).toHaveBeenCalledTimes(1); 69 | expect(onChange).toHaveBeenCalledTimes(1); 70 | expect(onChange).toHaveBeenCalledWith( 71 | {}, 72 | { 73 | flagsChanged: Object.keys(defaultState.flags), 74 | isFromServer: true, 75 | traitsChanged: ['number_trait', 'string_trait'], 76 | }, 77 | { error: null, isFetching: false, isLoading: false, source: 'SERVER' }, 78 | ); 79 | expect(getStateToCheck(flagsmith.getState())).toEqual({ 80 | ...identityState, 81 | identity: testIdentityWithTraits, 82 | evaluationContext: { 83 | ...identityState.evaluationContext, 84 | identity: { 85 | ...identityState.evaluationContext.identity, 86 | identifier: testIdentityWithTraits, 87 | }, 88 | }, 89 | }); 90 | }); 91 | test('should reject initialize with identity no key', async () => { 92 | const onChange = jest.fn(); 93 | const { flagsmith, initConfig } = getFlagsmith({ 94 | onChange, 95 | evaluationContext: { environment: { apiKey: '' } }, 96 | }); 97 | await expect(flagsmith.init(initConfig)).rejects.toThrow(Error); 98 | }); 99 | test('should sanitise api url', async () => { 100 | const onChange = jest.fn(); 101 | const { flagsmith,initConfig } = getFlagsmith({ 102 | api:'https://edge.api.flagsmith.com/api/v1/', 103 | onChange, 104 | }); 105 | await flagsmith.init(initConfig) 106 | expect(flagsmith.getState().api).toBe('https://edge.api.flagsmith.com/api/v1/'); 107 | const { flagsmith:flagsmith2 } = getFlagsmith({ 108 | api:'https://edge.api.flagsmith.com/api/v1', 109 | onChange, 110 | }); 111 | await flagsmith2.init(initConfig) 112 | expect(flagsmith2.getState().api).toBe('https://edge.api.flagsmith.com/api/v1/'); 113 | }); 114 | test('should reject initialize with identity bad key', async () => { 115 | const onChange = jest.fn(); 116 | const { flagsmith, initConfig, mockFetch } = getFlagsmith({ onChange, environmentID: 'bad' }); 117 | mockFetch.mockResolvedValueOnce({ status: 404, text: async () => '' }); 118 | await expect(flagsmith.init(initConfig)).rejects.toThrow(Error); 119 | }); 120 | test('identifying with new identity should not carry over previous traits for different identity', async () => { 121 | const onChange = jest.fn(); 122 | const identityA = `test_identity_a`; 123 | const identityB = `test_identity_b`; 124 | const { flagsmith, initConfig, mockFetch } = getFlagsmith({ 125 | onChange, 126 | identity: identityA, 127 | traits: { a: `example` }, 128 | }); 129 | mockFetch.mockResolvedValueOnce({ 130 | status: 200, 131 | text: () => fs.readFile(`./test/data/identities_${identityA}.json`, 'utf8'), 132 | }); 133 | await flagsmith.init(initConfig); 134 | expect(flagsmith.getTrait('a')).toEqual(`example`); 135 | mockFetch.mockResolvedValueOnce({ 136 | status: 200, 137 | text: () => fs.readFile(`./test/data/identities_${identityB}.json`, 'utf8'), 138 | }); 139 | expect(flagsmith.identity).toEqual(identityA); 140 | await flagsmith.identify(identityB); 141 | expect(flagsmith.identity).toEqual(identityB); 142 | expect(flagsmith.getTrait('a')).toEqual(undefined); 143 | mockFetch.mockResolvedValueOnce({ 144 | status: 200, 145 | text: () => fs.readFile(`./test/data/identities_${identityA}.json`, 'utf8'), 146 | }); 147 | await flagsmith.identify(identityA); 148 | expect(flagsmith.getTrait('a')).toEqual(`example`); 149 | mockFetch.mockResolvedValueOnce({ 150 | status: 200, 151 | text: () => fs.readFile(`./test/data/identities_${identityB}.json`, 'utf8'), 152 | }); 153 | await flagsmith.identify(identityB); 154 | expect(flagsmith.getTrait('a')).toEqual(undefined); 155 | }); 156 | test('identifying with transient identity should request the API correctly', async () => { 157 | const onChange = jest.fn(); 158 | const testTransientIdentity = `test_transient_identity`; 159 | const evaluationContext = { 160 | identity: { identifier: testTransientIdentity, transient: true }, 161 | }; 162 | const { flagsmith, initConfig, mockFetch } = getFlagsmith({ onChange, evaluationContext }); 163 | mockFetch.mockResolvedValueOnce({ 164 | status: 200, 165 | text: () => fs.readFile(`./test/data/identities_${testTransientIdentity}.json`, 'utf8'), 166 | }); 167 | await flagsmith.init(initConfig); 168 | expect(mockFetch).toHaveBeenCalledWith( 169 | `https://edge.api.flagsmith.com/api/v1/identities/?identifier=${testTransientIdentity}&transient=true`, 170 | expect.objectContaining({ method: 'GET' }), 171 | ); 172 | }); 173 | test('identifying with transient traits should request the API correctly', async () => { 174 | const onChange = jest.fn(); 175 | const testIdentityWithTransientTraits = `test_identity_with_transient_traits`; 176 | const evaluationContext = { 177 | identity: { 178 | identifier: testIdentityWithTransientTraits, 179 | traits: { 180 | number_trait: { value: 1 }, 181 | string_trait: { value: 'Example' }, 182 | transient_trait: { value: 'Example', transient: true }, 183 | }, 184 | }, 185 | }; 186 | const { flagsmith, initConfig, mockFetch } = getFlagsmith({ onChange, evaluationContext }); 187 | mockFetch.mockResolvedValueOnce({ 188 | status: 200, 189 | text: () => fs.readFile(`./test/data/identities_${testIdentityWithTransientTraits}.json`, 'utf8'), 190 | }); 191 | await flagsmith.init(initConfig); 192 | expect(mockFetch).toHaveBeenCalledWith( 193 | 'https://edge.api.flagsmith.com/api/v1/identities/', 194 | expect.objectContaining({ 195 | method: 'POST', 196 | body: JSON.stringify({ 197 | identifier: testIdentityWithTransientTraits, 198 | traits: [ 199 | { 200 | trait_key: 'number_trait', 201 | trait_value: 1, 202 | }, 203 | { 204 | trait_key: 'string_trait', 205 | trait_value: 'Example', 206 | }, 207 | { 208 | trait_key: 'transient_trait', 209 | trait_value: 'Example', 210 | transient: true, 211 | }, 212 | ], 213 | }), 214 | }), 215 | ); 216 | }); 217 | test('should not reject but call onError, when the API cannot be reached with the cache populated', async () => { 218 | const onError = jest.fn(); 219 | const { flagsmith, initConfig, AsyncStorage } = getFlagsmith({ 220 | cacheFlags: true, 221 | fetch: async () => { 222 | return Promise.resolve({ text: () => Promise.resolve('Mocked fetch error'), ok: false, status: 401 }); 223 | }, 224 | onError, 225 | }); 226 | await AsyncStorage.setItem(FLAGSMITH_KEY, JSON.stringify(defaultState)); 227 | await flagsmith.init(initConfig); 228 | 229 | expect(getStateToCheck(flagsmith.getState())).toEqual(defaultState); 230 | 231 | await waitFor(() => { 232 | expect(onError).toHaveBeenCalledTimes(1); 233 | }); 234 | expect(onError).toHaveBeenCalledWith(new Error('Mocked fetch error')); 235 | }); 236 | test('should not reject when the API cannot be reached but default flags are set', async () => { 237 | const { flagsmith, initConfig } = getFlagsmith({ 238 | defaultFlags: defaultState.flags, 239 | cacheFlags: true, 240 | fetch: async () => { 241 | return Promise.resolve({ text: () => Promise.resolve('Mocked fetch error'), ok: false, status: 401 }); 242 | }, 243 | }); 244 | await flagsmith.init(initConfig); 245 | 246 | expect(getStateToCheck(flagsmith.getState())).toEqual(defaultState); 247 | }); 248 | test('should not reject but call onError, when the identities/ API cannot be reached with the cache populated', async () => { 249 | const onError = jest.fn(); 250 | const { flagsmith, initConfig, AsyncStorage } = getFlagsmith({ 251 | evaluationContext: identityState.evaluationContext, 252 | cacheFlags: true, 253 | fetch: async () => { 254 | return Promise.resolve({ text: () => Promise.resolve('Mocked fetch error'), ok: false, status: 401 }); 255 | }, 256 | onError, 257 | }); 258 | await AsyncStorage.setItem(FLAGSMITH_KEY, JSON.stringify(identityState)); 259 | await flagsmith.init(initConfig); 260 | 261 | expect(getStateToCheck(flagsmith.getState())).toEqual({ 262 | ...identityState, 263 | evaluationContext: { 264 | ...identityState.evaluationContext, 265 | identity: { 266 | ...identityState.evaluationContext.identity, 267 | traits: {}, 268 | }, 269 | }, 270 | }); 271 | 272 | await waitFor(() => { 273 | expect(onError).toHaveBeenCalledTimes(1); 274 | }); 275 | expect(onError).toHaveBeenCalledWith(new Error('Mocked fetch error')); 276 | }); 277 | test('should call onError when the API cannot be reached with cacheFlags enabled but no cache exists', async () => { 278 | const onError = jest.fn(); 279 | const { flagsmith, initConfig } = getFlagsmith({ 280 | cacheFlags: true, 281 | fetch: async () => { 282 | return Promise.resolve({ text: () => Promise.resolve('Mocked fetch error'), ok: false, status: 401 }); 283 | }, 284 | onError, 285 | }); 286 | // NOTE: No AsyncStorage.setItem() - cache is empty, and no defaultFlags provided 287 | 288 | await expect(flagsmith.init(initConfig)).rejects.toThrow('Mocked fetch error'); 289 | expect(onError).toHaveBeenCalledTimes(1); 290 | expect(onError).toHaveBeenCalledWith(new Error('Mocked fetch error')); 291 | }); 292 | test('should send app name and version headers when provided', async () => { 293 | const onChange = jest.fn(); 294 | const { flagsmith, initConfig, AsyncStorage, mockFetch } = getFlagsmith({ 295 | onChange, 296 | applicationMetadata: { 297 | name: 'Test App', 298 | version: '1.2.3', 299 | }, 300 | }); 301 | 302 | await flagsmith.init(initConfig); 303 | expect(mockFetch).toHaveBeenCalledTimes(1); 304 | expect(mockFetch).toHaveBeenCalledWith( 305 | expect.any(String), 306 | expect.objectContaining({ 307 | headers: expect.objectContaining({ 308 | 'Flagsmith-Application-Name': 'Test App', 309 | 'Flagsmith-Application-Version': '1.2.3', 310 | 'Flagsmith-SDK-User-Agent': `flagsmith-js-sdk/${SDK_VERSION}`, 311 | }), 312 | }), 313 | ); 314 | 315 | }); 316 | test('should send app name headers when provided', async () => { 317 | const onChange = jest.fn(); 318 | const { flagsmith, initConfig, AsyncStorage, mockFetch } = getFlagsmith({ 319 | onChange, 320 | applicationMetadata: { 321 | name: 'Test App', 322 | }, 323 | }); 324 | 325 | await flagsmith.init(initConfig); 326 | expect(mockFetch).toHaveBeenCalledTimes(1); 327 | expect(mockFetch).toHaveBeenCalledWith( 328 | expect.any(String), 329 | expect.objectContaining({ 330 | headers: expect.objectContaining({ 331 | 'Flagsmith-Application-Name': 'Test App', 332 | }), 333 | }), 334 | ); 335 | 336 | }); 337 | 338 | test('should not send app name and version headers when not provided', async () => { 339 | const onChange = jest.fn(); 340 | const { flagsmith, initConfig, AsyncStorage, mockFetch } = getFlagsmith({ 341 | onChange, 342 | }); 343 | 344 | await flagsmith.init(initConfig); 345 | expect(mockFetch).toHaveBeenCalledTimes(1); 346 | expect(mockFetch).toHaveBeenCalledWith( 347 | expect.any(String), 348 | expect.objectContaining({ 349 | headers: expect.not.objectContaining({ 350 | 'Flagsmith-Application-Name': 'Test App', 351 | 'Flagsmith-Application-Version': '1.2.3', 352 | }), 353 | }), 354 | ); 355 | }); 356 | 357 | }); 358 | -------------------------------------------------------------------------------- /test/angular-fetch.test.ts: -------------------------------------------------------------------------------- 1 | import angularFetch from '../utils/angular-fetch'; 2 | import { createFlagsmithInstance } from '../lib/flagsmith'; 3 | import MockAsyncStorage from './mocks/async-storage-mock'; 4 | import { environmentID } from './test-constants'; 5 | import { promises as fs } from 'fs'; 6 | 7 | describe('Angular HttpClient Fetch Adapter', () => { 8 | it('should return response with status property', async () => { 9 | const mockAngularHttpClient = { 10 | get: jest.fn().mockReturnValue({ 11 | subscribe: (onSuccess: any, onError: any) => { 12 | onSuccess({ 13 | status: 200, 14 | body: JSON.stringify({ flags: [] }), 15 | headers: { get: (name: string) => null } 16 | }); 17 | } 18 | }) 19 | }; 20 | 21 | const fetchAdapter = angularFetch(mockAngularHttpClient); 22 | const response: any = await fetchAdapter('https://api.example.com/flags', { 23 | headers: { 'Content-Type': 'application/json' }, 24 | method: 'GET', 25 | body: '' 26 | }); 27 | 28 | expect(response.status).toBe(200); 29 | expect(response.ok).toBe(true); 30 | }); 31 | 32 | it('should handle errors with status property and proper error messages', async () => { 33 | const mockAngularHttpClient = { 34 | get: jest.fn().mockReturnValue({ 35 | subscribe: (onSuccess: any, onError: any) => { 36 | onError({ 37 | status: 401, 38 | error: 'Unauthorized', 39 | headers: { get: (name: string) => null } 40 | }); 41 | } 42 | }) 43 | }; 44 | 45 | const fetchAdapter = angularFetch(mockAngularHttpClient); 46 | const response: any = await fetchAdapter('https://api.example.com/flags', { 47 | headers: { 'Content-Type': 'application/json' }, 48 | method: 'GET', 49 | body: '' 50 | }); 51 | 52 | expect(response.status).toBe(401); 53 | expect(response.ok).toBe(false); 54 | 55 | const errorText = await response.text(); 56 | expect(errorText).toBe('Unauthorized'); 57 | }); 58 | 59 | it('should initialize Flagsmith successfully with Angular HttpClient', async () => { 60 | const mockAngularHttpClient = { 61 | get: jest.fn().mockReturnValue({ 62 | subscribe: async (onSuccess: any) => { 63 | const body = await fs.readFile('./test/data/flags.json', 'utf8'); 64 | onSuccess({ 65 | status: 200, 66 | body, 67 | headers: { get: (name: string) => null } 68 | }); 69 | } 70 | }) 71 | }; 72 | 73 | const flagsmith = createFlagsmithInstance(); 74 | const fetchAdapter = angularFetch(mockAngularHttpClient); 75 | const AsyncStorage = new MockAsyncStorage(); 76 | 77 | // @ts-ignore 78 | flagsmith.canUseStorage = true; 79 | 80 | await expect( 81 | flagsmith.init({ 82 | evaluationContext: { environment: { apiKey: environmentID } }, 83 | fetch: fetchAdapter, 84 | AsyncStorage 85 | }) 86 | ).resolves.not.toThrow(); 87 | 88 | expect(flagsmith.hasFeature('hero')).toBe(true); 89 | }); 90 | 91 | it('should handle POST requests correctly', async () => { 92 | const mockAngularHttpClient = { 93 | post: jest.fn().mockReturnValue({ 94 | subscribe: (onSuccess: any, onError: any) => { 95 | onSuccess({ 96 | status: 201, 97 | body: JSON.stringify({ success: true }), 98 | headers: { get: (name: string) => null } 99 | }); 100 | } 101 | }) 102 | }; 103 | 104 | const fetchAdapter = angularFetch(mockAngularHttpClient); 105 | const response: any = await fetchAdapter('https://api.example.com/create', { 106 | headers: { 'Content-Type': 'application/json' }, 107 | method: 'POST', 108 | body: JSON.stringify({ data: 'test' }) 109 | }); 110 | 111 | expect(mockAngularHttpClient.post).toHaveBeenCalledWith( 112 | 'https://api.example.com/create', 113 | JSON.stringify({ data: 'test' }), 114 | { headers: { 'Content-Type': 'application/json' }, observe: 'response', responseType: 'text' } 115 | ); 116 | expect(response.status).toBe(201); 117 | expect(response.ok).toBe(true); 118 | }); 119 | 120 | it('should handle PUT requests correctly', async () => { 121 | const mockAngularHttpClient = { 122 | post: jest.fn().mockReturnValue({ 123 | subscribe: (onSuccess: any, onError: any) => { 124 | onSuccess({ 125 | status: 200, 126 | body: JSON.stringify({ updated: true }), 127 | headers: { get: (name: string) => null } 128 | }); 129 | } 130 | }) 131 | }; 132 | 133 | const fetchAdapter = angularFetch(mockAngularHttpClient); 134 | const response: any = await fetchAdapter('https://api.example.com/update', { 135 | headers: { 'Content-Type': 'application/json' }, 136 | method: 'PUT', 137 | body: JSON.stringify({ data: 'updated' }) 138 | }); 139 | 140 | expect(mockAngularHttpClient.post).toHaveBeenCalledWith( 141 | 'https://api.example.com/update', 142 | JSON.stringify({ data: 'updated' }), 143 | { headers: { 'Content-Type': 'application/json' }, observe: 'response', responseType: 'text' } 144 | ); 145 | expect(response.status).toBe(200); 146 | expect(response.ok).toBe(true); 147 | }); 148 | 149 | it('should retrieve headers correctly', async () => { 150 | const mockAngularHttpClient = { 151 | get: jest.fn().mockReturnValue({ 152 | subscribe: (onSuccess: any, onError: any) => { 153 | onSuccess({ 154 | status: 200, 155 | body: 'test', 156 | headers: { 157 | get: (name: string) => { 158 | if (name === 'Content-Type') return 'application/json'; 159 | if (name === 'X-Custom-Header') return 'custom-value'; 160 | return null; 161 | } 162 | } 163 | }); 164 | } 165 | }) 166 | }; 167 | 168 | const fetchAdapter = angularFetch(mockAngularHttpClient); 169 | const response: any = await fetchAdapter('https://api.example.com/test', { 170 | headers: {}, 171 | method: 'GET', 172 | body: '' 173 | }); 174 | 175 | expect(response.headers.get('Content-Type')).toBe('application/json'); 176 | expect(response.headers.get('X-Custom-Header')).toBe('custom-value'); 177 | expect(response.headers.get('Non-Existent')).toBe(null); 178 | }); 179 | 180 | it('should handle different error status codes correctly', async () => { 181 | const testCases = [ 182 | { status: 400, expectedOk: false, description: 'Bad Request' }, 183 | { status: 403, expectedOk: false, description: 'Forbidden' }, 184 | { status: 404, expectedOk: false, description: 'Not Found' }, 185 | { status: 500, expectedOk: false, description: 'Internal Server Error' }, 186 | { status: 503, expectedOk: false, description: 'Service Unavailable' } 187 | ]; 188 | 189 | for (const testCase of testCases) { 190 | const mockAngularHttpClient = { 191 | get: jest.fn().mockReturnValue({ 192 | subscribe: (_onSuccess: any, onError: any) => { 193 | onError({ 194 | status: testCase.status, 195 | error: testCase.description, 196 | headers: { get: (_name: string) => null } 197 | }); 198 | } 199 | }) 200 | }; 201 | 202 | const fetchAdapter = angularFetch(mockAngularHttpClient); 203 | const response: any = await fetchAdapter('https://api.example.com/test', { 204 | headers: {}, 205 | method: 'GET', 206 | body: '' 207 | }); 208 | 209 | expect(response.status).toBe(testCase.status); 210 | expect(response.ok).toBe(testCase.expectedOk); 211 | const errorText = await response.text(); 212 | expect(errorText).toBe(testCase.description); 213 | } 214 | }); 215 | 216 | it('should handle 3xx redirect status codes', async () => { 217 | const mockAngularHttpClient = { 218 | get: jest.fn().mockReturnValue({ 219 | subscribe: (onSuccess: any, onError: any) => { 220 | onSuccess({ 221 | status: 301, 222 | body: 'Moved Permanently', 223 | headers: { get: (name: string) => null } 224 | }); 225 | } 226 | }) 227 | }; 228 | 229 | const fetchAdapter = angularFetch(mockAngularHttpClient); 230 | const response: any = await fetchAdapter('https://api.example.com/redirect', { 231 | headers: {}, 232 | method: 'GET', 233 | body: '' 234 | }); 235 | 236 | expect(response.status).toBe(301); 237 | expect(response.ok).toBe(false); // 3xx should have ok: false 238 | }); 239 | 240 | it('should use fallback status codes when status is missing', async () => { 241 | // Test success case without status 242 | const mockSuccessClient = { 243 | get: jest.fn().mockReturnValue({ 244 | subscribe: (onSuccess: any, _onError: any) => { 245 | onSuccess({ 246 | body: 'success', 247 | headers: { get: (name: string) => null } 248 | }); 249 | } 250 | }) 251 | }; 252 | 253 | const fetchAdapter1 = angularFetch(mockSuccessClient); 254 | const response1: any = await fetchAdapter1('https://api.example.com/test', { 255 | headers: {}, 256 | method: 'GET', 257 | body: '' 258 | }); 259 | 260 | expect(response1.status).toBe(200); // Defaults to 200 for success 261 | expect(response1.ok).toBe(true); 262 | 263 | // Test error case without status 264 | const mockErrorClient = { 265 | get: jest.fn().mockReturnValue({ 266 | subscribe: (_onSuccess: any, onError: any) => { 267 | onError({ 268 | message: 'Network error', 269 | headers: { get: (name: string) => null } 270 | }); 271 | } 272 | }) 273 | }; 274 | 275 | const fetchAdapter2 = angularFetch(mockErrorClient); 276 | const response2: any = await fetchAdapter2('https://api.example.com/test', { 277 | headers: {}, 278 | method: 'GET', 279 | body: '' 280 | }); 281 | 282 | expect(response2.status).toBe(500); // Defaults to 500 for errors 283 | expect(response2.ok).toBe(false); 284 | }); 285 | 286 | it('should use fallback error messages when error and message are missing', async () => { 287 | const mockAngularHttpClient = { 288 | get: jest.fn().mockReturnValue({ 289 | subscribe: (_onSuccess: any, onError: any) => { 290 | onError({ 291 | status: 500, 292 | headers: { get: (_name: string) => null } 293 | }); 294 | } 295 | }) 296 | }; 297 | 298 | const fetchAdapter = angularFetch(mockAngularHttpClient); 299 | const response: any = await fetchAdapter('https://api.example.com/test', { 300 | headers: {}, 301 | method: 'GET', 302 | body: '' 303 | }); 304 | 305 | const errorText = await response.text(); 306 | expect(errorText).toBe(''); // Falls back to empty string 307 | }); 308 | 309 | it('should handle unsupported HTTP methods with 405 status', async () => { 310 | const mockAngularHttpClient = { 311 | get: jest.fn(), 312 | post: jest.fn(), 313 | put: jest.fn() 314 | }; 315 | 316 | const fetchAdapter = angularFetch(mockAngularHttpClient); 317 | const response: any = await fetchAdapter('https://api.example.com/test', { 318 | headers: {}, 319 | method: 'DELETE' as any, // Using unsupported method 320 | body: '' 321 | }); 322 | 323 | expect(response.status).toBe(405); 324 | expect(response.ok).toBe(false); 325 | const errorText = await response.text(); 326 | expect(errorText).toContain('Unsupported method'); 327 | expect(errorText).toContain('DELETE'); 328 | }); 329 | 330 | it('should stringify JSON objects in text() method', async () => { 331 | // Test case 1: body contains a JSON object 332 | const mockAngularHttpClient1 = { 333 | get: jest.fn().mockReturnValue({ 334 | subscribe: (onSuccess: any, onError: any) => { 335 | onSuccess({ 336 | status: 200, 337 | body: { flags: [], message: 'Success' }, 338 | headers: { get: (name: string) => null } 339 | }); 340 | } 341 | }) 342 | }; 343 | 344 | const fetchAdapter1 = angularFetch(mockAngularHttpClient1); 345 | const response1: any = await fetchAdapter1('https://api.example.com/test', { 346 | headers: {}, 347 | method: 'GET', 348 | body: '' 349 | }); 350 | 351 | const text1 = await response1.text(); 352 | expect(text1).toBe(JSON.stringify({ flags: [], message: 'Success' })); 353 | 354 | // Test case 2: error contains a JSON object 355 | const mockAngularHttpClient2 = { 356 | get: jest.fn().mockReturnValue({ 357 | subscribe: (onSuccess: any, onError: any) => { 358 | onError({ 359 | status: 400, 360 | error: { code: 'INVALID_REQUEST', details: 'Bad data' }, 361 | headers: { get: (name: string) => null } 362 | }); 363 | } 364 | }) 365 | }; 366 | 367 | const fetchAdapter2 = angularFetch(mockAngularHttpClient2); 368 | const response2: any = await fetchAdapter2('https://api.example.com/test', { 369 | headers: {}, 370 | method: 'GET', 371 | body: '' 372 | }); 373 | 374 | const text2 = await response2.text(); 375 | expect(text2).toBe(JSON.stringify({ code: 'INVALID_REQUEST', details: 'Bad data' })); 376 | 377 | // Test case 3: body contains a string (should not stringify) 378 | const mockAngularHttpClient3 = { 379 | get: jest.fn().mockReturnValue({ 380 | subscribe: (onSuccess: any, onError: any) => { 381 | onSuccess({ 382 | status: 200, 383 | body: 'plain text response', 384 | headers: { get: (name: string) => null } 385 | }); 386 | } 387 | }) 388 | }; 389 | 390 | const fetchAdapter3 = angularFetch(mockAngularHttpClient3); 391 | const response3: any = await fetchAdapter3('https://api.example.com/test', { 392 | headers: {}, 393 | method: 'GET', 394 | body: '' 395 | }); 396 | 397 | const text3 = await response3.text(); 398 | expect(text3).toBe('plain text response'); 399 | 400 | // Test case 4: message contains a string (should not stringify) 401 | const mockAngularHttpClient4 = { 402 | get: jest.fn().mockReturnValue({ 403 | subscribe: (onSuccess: any, onError: any) => { 404 | onError({ 405 | status: 500, 406 | message: 'Internal Server Error', 407 | headers: { get: (name: string) => null } 408 | }); 409 | } 410 | }) 411 | }; 412 | 413 | const fetchAdapter4 = angularFetch(mockAngularHttpClient4); 414 | const response4: any = await fetchAdapter4('https://api.example.com/test', { 415 | headers: {}, 416 | method: 'GET', 417 | body: '' 418 | }); 419 | 420 | const text4 = await response4.text(); 421 | expect(text4).toBe('Internal Server Error'); 422 | }); 423 | }); 424 | -------------------------------------------------------------------------------- /flagsmith-core.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ClientEvaluationContext, 3 | DynatraceObject, 4 | GetValueOptions, 5 | HasFeatureOptions, 6 | IDatadogRum, 7 | IFlags, 8 | IFlagsmith, 9 | IFlagsmithResponse, 10 | IFlagsmithTrait, 11 | IInitConfig, 12 | ISentryClient, 13 | IState, 14 | ITraits, 15 | LoadingState, 16 | OnChange, 17 | Traits, 18 | } from './types'; 19 | // @ts-ignore 20 | import deepEqual from 'fast-deep-equal'; 21 | import { AsyncStorageType } from './utils/async-storage'; 22 | import getChanges from './utils/get-changes'; 23 | import angularFetch from './utils/angular-fetch'; 24 | import setDynatraceValue from './utils/set-dynatrace-value'; 25 | import { EvaluationContext } from './evaluation-context'; 26 | import { isTraitEvaluationContext, toEvaluationContext, toTraitEvaluationContextObject } from './utils/types'; 27 | import { ensureTrailingSlash } from './utils/ensureTrailingSlash'; 28 | import { SDK_VERSION } from './utils/version'; 29 | 30 | export enum FlagSource { 31 | "NONE" = "NONE", 32 | "DEFAULT_FLAGS" = "DEFAULT_FLAGS", 33 | "CACHE" = "CACHE", 34 | "SERVER" = "SERVER", 35 | } 36 | 37 | export type LikeFetch = (input: Partial, init?: Partial) => Promise> 38 | let _fetch: LikeFetch; 39 | 40 | type RequestOptions = { 41 | method: "GET"|"PUT"|"DELETE"|"POST", 42 | headers: Record 43 | body?: string 44 | } 45 | 46 | let AsyncStorage: AsyncStorageType = null; 47 | const DEFAULT_FLAGSMITH_KEY = "FLAGSMITH_DB"; 48 | const DEFAULT_FLAGSMITH_EVENT = "FLAGSMITH_EVENT"; 49 | let FlagsmithEvent = DEFAULT_FLAGSMITH_EVENT; 50 | const defaultAPI = 'https://edge.api.flagsmith.com/api/v1/'; 51 | let eventSource: typeof EventSource; 52 | const initError = function(caller: string) { 53 | return "Attempted to " + caller + " a user before calling flagsmith.init. Call flagsmith.init first, if you wish to prevent it sending a request for flags, call init with preventFetch:true." 54 | } 55 | 56 | type Config = { 57 | browserlessStorage?: boolean, 58 | fetch?: LikeFetch, 59 | AsyncStorage?: AsyncStorageType, 60 | eventSource?: any, 61 | applicationMetadata?: IInitConfig['applicationMetadata'], 62 | }; 63 | 64 | const FLAGSMITH_CONFIG_ANALYTICS_KEY = "flagsmith_value_"; 65 | const FLAGSMITH_FLAG_ANALYTICS_KEY = "flagsmith_enabled_"; 66 | const FLAGSMITH_TRAIT_ANALYTICS_KEY = "flagsmith_trait_"; 67 | 68 | const Flagsmith = class { 69 | _trigger?:(()=>void)|null= null 70 | _triggerLoadingState?:(()=>void)|null= null 71 | timestamp: number|null = null 72 | isLoading = false 73 | eventSource:EventSource|null = null 74 | applicationMetadata: IInitConfig['applicationMetadata']; 75 | constructor(props: Config) { 76 | if (props.fetch) { 77 | _fetch = props.fetch as LikeFetch; 78 | } else { 79 | _fetch = (typeof fetch !== 'undefined' ? fetch : global?.fetch) as LikeFetch; 80 | } 81 | 82 | this.canUseStorage = typeof window !== 'undefined' || !!props.browserlessStorage; 83 | this.applicationMetadata = props.applicationMetadata; 84 | 85 | this.log("Constructing flagsmith instance " + props) 86 | if (props.eventSource) { 87 | eventSource = props.eventSource; 88 | } 89 | if (props.AsyncStorage) { 90 | AsyncStorage = props.AsyncStorage; 91 | } 92 | } 93 | 94 | getFlags = () => { 95 | const { api, evaluationContext } = this; 96 | this.log("Get Flags") 97 | this.isLoading = true; 98 | 99 | if (!this.loadingState.isFetching) { 100 | this.setLoadingState({ 101 | ...this.loadingState, 102 | isFetching: true 103 | }) 104 | } 105 | const previousIdentity = `${this.getContext().identity}`; 106 | const handleResponse = (response: IFlagsmithResponse | null) => { 107 | if(!response || previousIdentity !== `${this.getContext().identity}`) { 108 | return // getJSON returned null due to request/response mismatch 109 | } 110 | let { flags: features, traits }: IFlagsmithResponse = response 111 | const {identifier} = response 112 | this.isLoading = false; 113 | // Handle server response 114 | const flags: IFlags = {}; 115 | const userTraits: Traits = {}; 116 | features = features || []; 117 | traits = traits || []; 118 | features.forEach(feature => { 119 | flags[feature.feature.name.toLowerCase().replace(/ /g, '_')] = { 120 | id: feature.feature.id, 121 | enabled: feature.enabled, 122 | value: feature.feature_state_value 123 | }; 124 | }); 125 | traits.forEach(trait => { 126 | userTraits[trait.trait_key.toLowerCase().replace(/ /g, '_')] = { 127 | transient: trait.transient, 128 | value: trait.trait_value, 129 | } 130 | }); 131 | 132 | this.oldFlags = { ...this.flags }; 133 | const flagsChanged = getChanges(this.oldFlags, flags); 134 | const traitsChanged = getChanges(this.evaluationContext.identity?.traits, userTraits); 135 | if (identifier || Object.keys(userTraits).length) { 136 | this.evaluationContext.identity = { 137 | ...this.evaluationContext.identity, 138 | traits: userTraits, 139 | }; 140 | if (identifier) { 141 | this.evaluationContext.identity.identifier = identifier; 142 | this.identity = identifier; 143 | } 144 | } 145 | this.flags = flags; 146 | this.updateStorage(); 147 | this._onChange(this.oldFlags, { 148 | isFromServer: true, 149 | flagsChanged, 150 | traitsChanged 151 | }, this._loadedState(null, FlagSource.SERVER)); 152 | 153 | if (this.datadogRum) { 154 | try { 155 | if (this.datadogRum!.trackTraits) { 156 | const traits: Parameters["0"] = {}; 157 | Object.keys(this.evaluationContext.identity?.traits || {}).map((key) => { 158 | traits[FLAGSMITH_TRAIT_ANALYTICS_KEY + key] = this.getTrait(key); 159 | }); 160 | const datadogRumData = { 161 | ...this.datadogRum.client.getUser(), 162 | id: this.datadogRum.client.getUser().id || this.evaluationContext.identity?.identifier, 163 | ...traits, 164 | }; 165 | this.log("Setting Datadog user", datadogRumData); 166 | this.datadogRum.client.setUser(datadogRumData); 167 | } 168 | } catch (e) { 169 | console.error(e) 170 | } 171 | } 172 | if (this.dtrum) { 173 | try { 174 | const traits: DynatraceObject = { 175 | javaDouble: {}, 176 | date: {}, 177 | shortString: {}, 178 | javaLongOrObject: {}, 179 | } 180 | Object.keys(this.flags).map((key) => { 181 | setDynatraceValue(traits, FLAGSMITH_CONFIG_ANALYTICS_KEY + key, this.getValue(key, { skipAnalytics: true })) 182 | setDynatraceValue(traits, FLAGSMITH_FLAG_ANALYTICS_KEY + key, this.hasFeature(key, { skipAnalytics: true })) 183 | }) 184 | Object.keys(this.evaluationContext.identity?.traits || {}).map((key) => { 185 | setDynatraceValue(traits, FLAGSMITH_TRAIT_ANALYTICS_KEY + key, this.getTrait(key)) 186 | }) 187 | this.log("Sending javaLongOrObject traits to dynatrace", traits.javaLongOrObject) 188 | this.log("Sending date traits to dynatrace", traits.date) 189 | this.log("Sending shortString traits to dynatrace", traits.shortString) 190 | this.log("Sending javaDouble to dynatrace", traits.javaDouble) 191 | // @ts-expect-error 192 | this.dtrum.sendSessionProperties( 193 | traits.javaLongOrObject, traits.date, traits.shortString, traits.javaDouble 194 | ) 195 | } catch (e) { 196 | console.error(e) 197 | } 198 | } 199 | 200 | }; 201 | 202 | if (evaluationContext.identity) { 203 | return Promise.all([ 204 | (evaluationContext.identity.traits && Object.keys(evaluationContext.identity.traits).length) || !evaluationContext.identity.identifier ? 205 | this.getJSON(api + 'identities/', "POST", JSON.stringify({ 206 | "identifier": evaluationContext.identity.identifier, 207 | "transient": evaluationContext.identity.transient, 208 | traits: Object.entries(evaluationContext.identity.traits!).map(([tKey, tContext]) => { 209 | return { 210 | trait_key: tKey, 211 | trait_value: tContext?.value, 212 | transient: tContext?.transient, 213 | } 214 | }).filter((v) => { 215 | if (typeof v.trait_value === 'undefined') { 216 | this.log("Warning - attempted to set an undefined trait value for key", v.trait_key) 217 | return false 218 | } 219 | return true 220 | }) 221 | })) : 222 | this.getJSON(api + 'identities/?identifier=' + encodeURIComponent(evaluationContext.identity.identifier) + (evaluationContext.identity.transient ? '&transient=true' : '')), 223 | ]) 224 | .then((res) => { 225 | this.evaluationContext.identity = {...this.evaluationContext.identity, traits: {}} 226 | return handleResponse(res?.[0] as IFlagsmithResponse | null) 227 | }).catch(({ message }) => { 228 | const error = new Error(message) 229 | return Promise.reject(error) 230 | }); 231 | } else { 232 | return this.getJSON(api + "flags/") 233 | .then((res) => { 234 | return handleResponse({ flags: res as IFlagsmithResponse['flags'], traits:undefined }) 235 | }) 236 | } 237 | }; 238 | 239 | analyticsFlags = () => { 240 | const { api } = this; 241 | 242 | if (!this.evaluationEvent || !this.evaluationContext.environment || !this.evaluationEvent[this.evaluationContext.environment.apiKey]) { 243 | return 244 | } 245 | 246 | if (this.evaluationEvent && Object.getOwnPropertyNames(this.evaluationEvent).length !== 0 && Object.getOwnPropertyNames(this.evaluationEvent[this.evaluationContext.environment.apiKey]).length !== 0) { 247 | return this.getJSON(api + 'analytics/flags/', 'POST', JSON.stringify(this.evaluationEvent[this.evaluationContext.environment.apiKey])) 248 | .then((res) => { 249 | if (!this.evaluationContext.environment) { 250 | return; 251 | } 252 | const state = this.getState(); 253 | if (!this.evaluationEvent) { 254 | this.evaluationEvent = {} 255 | } 256 | this.evaluationEvent[this.evaluationContext.environment.apiKey] = {} 257 | this.setState({ 258 | ...state, 259 | evaluationEvent: this.evaluationEvent, 260 | }); 261 | this.updateEventStorage(); 262 | }).catch((err) => { 263 | this.log("Exception fetching evaluationEvent", err); 264 | }); 265 | } 266 | }; 267 | 268 | datadogRum: IDatadogRum | null = null; 269 | loadingState: LoadingState = {isLoading: true, isFetching: true, error: null, source: FlagSource.NONE} 270 | canUseStorage = false 271 | analyticsInterval: NodeJS.Timer | null= null 272 | api: string|null= null 273 | cacheFlags= false 274 | ts?: number 275 | enableAnalytics= false 276 | enableLogs= false 277 | evaluationContext: EvaluationContext= {} 278 | evaluationEvent: Record> | null= null 279 | flags:IFlags|null= null 280 | getFlagInterval: NodeJS.Timer|null= null 281 | headers?: object | null= null 282 | identity:string|null|undefined = null 283 | initialised= false 284 | oldFlags:IFlags|null= null 285 | onChange:IInitConfig['onChange']|null= null 286 | onError:IInitConfig['onError']|null = null 287 | ticks: number|null= null 288 | timer: number|null= null 289 | dtrum= null 290 | sentryClient: ISentryClient | null = null 291 | withTraits?: ITraits|null= null 292 | cacheOptions = {ttl:0, skipAPI: false, loadStale: false, storageKey: undefined as string|undefined} 293 | async init(config: IInitConfig) { 294 | const evaluationContext = toEvaluationContext(config.evaluationContext || this.evaluationContext); 295 | try { 296 | const { 297 | AsyncStorage: _AsyncStorage, 298 | _trigger, 299 | _triggerLoadingState, 300 | angularHttpClient, 301 | api = defaultAPI, 302 | applicationMetadata, 303 | cacheFlags, 304 | cacheOptions, 305 | datadogRum, 306 | defaultFlags, 307 | enableAnalytics, 308 | enableDynatrace, 309 | enableLogs, 310 | environmentID, 311 | eventSourceUrl= "https://realtime.flagsmith.com/", 312 | fetch: fetchImplementation, 313 | headers, 314 | identity, 315 | onChange, 316 | onError, 317 | preventFetch, 318 | realtime, 319 | sentryClient, 320 | state, 321 | traits, 322 | } = config; 323 | evaluationContext.environment = environmentID ? {apiKey: environmentID} : evaluationContext.environment; 324 | if (!evaluationContext.environment || !evaluationContext.environment.apiKey) { 325 | throw new Error('Please provide `evaluationContext.environment` with non-empty `apiKey`'); 326 | } 327 | evaluationContext.identity = identity || traits ? { 328 | identifier: identity, 329 | traits: traits ? Object.fromEntries( 330 | Object.entries(traits).map( 331 | ([tKey, tValue]) => [tKey, {value: tValue}] 332 | ) 333 | ) : {}, 334 | } : evaluationContext.identity; 335 | this.evaluationContext = evaluationContext; 336 | this.api = ensureTrailingSlash(api); 337 | this.headers = headers; 338 | this.getFlagInterval = null; 339 | this.analyticsInterval = null; 340 | this.onChange = onChange; 341 | const WRONG_FLAGSMITH_CONFIG = 'Wrong Flagsmith Configuration: preventFetch is true and no defaulFlags provided' 342 | this._trigger = _trigger || this._trigger; 343 | this._triggerLoadingState = _triggerLoadingState || this._triggerLoadingState; 344 | this.onError = (message: Error) => { 345 | this.setLoadingState({ 346 | ...this.loadingState, 347 | isFetching: false, 348 | isLoading: false, 349 | error: message, 350 | }); 351 | onError?.(message); 352 | }; 353 | this.enableLogs = enableLogs || false; 354 | this.cacheOptions = cacheOptions ? { skipAPI: !!cacheOptions.skipAPI, ttl: cacheOptions.ttl || 0, storageKey:cacheOptions.storageKey, loadStale: !!cacheOptions.loadStale } : this.cacheOptions; 355 | if (!this.cacheOptions.ttl && this.cacheOptions.skipAPI) { 356 | console.warn("Flagsmith: you have set a cache ttl of 0 and are skipping API calls, this means the API will not be hit unless you clear local storage.") 357 | } 358 | if (fetchImplementation) { 359 | _fetch = fetchImplementation; 360 | } 361 | this.enableAnalytics = enableAnalytics ? enableAnalytics : false; 362 | this.flags = Object.assign({}, defaultFlags) || {}; 363 | this.datadogRum = datadogRum || null; 364 | this.initialised = true; 365 | this.ticks = 10000; 366 | this.timer = this.enableLogs ? new Date().valueOf() : null; 367 | this.cacheFlags = typeof AsyncStorage !== 'undefined' && !!cacheFlags; 368 | this.applicationMetadata = applicationMetadata; 369 | 370 | FlagsmithEvent = DEFAULT_FLAGSMITH_EVENT + "_" + evaluationContext.environment.apiKey; 371 | 372 | if (_AsyncStorage) { 373 | AsyncStorage = _AsyncStorage; 374 | } 375 | if (realtime && typeof window !== 'undefined') { 376 | this.setupRealtime(eventSourceUrl, evaluationContext.environment.apiKey); 377 | } 378 | 379 | if (Object.keys(this.flags).length) { 380 | //Flags have been passed as part of SSR / default flags, update state silently for initial render 381 | this.loadingState = { 382 | ...this.loadingState, 383 | isLoading: false, 384 | source: FlagSource.DEFAULT_FLAGS 385 | } 386 | } 387 | 388 | this.setState(state as IState); 389 | 390 | this.log('Initialising with properties', config, this); 391 | 392 | if (enableDynatrace) { 393 | // @ts-expect-error Dynatrace's dtrum is exposed to global scope 394 | if (typeof dtrum === 'undefined') { 395 | console.error("You have attempted to enable dynatrace but dtrum is undefined, please check you have the Dynatrace RUM JavaScript API installed.") 396 | } else { 397 | // @ts-expect-error Dynatrace's dtrum is exposed to global scope 398 | this.dtrum = dtrum; 399 | } 400 | } 401 | 402 | if(sentryClient) { 403 | this.sentryClient = sentryClient 404 | } 405 | if (angularHttpClient) { 406 | // @ts-expect-error 407 | _fetch = angularFetch(angularHttpClient); 408 | } 409 | 410 | if (AsyncStorage && this.canUseStorage) { 411 | AsyncStorage.getItem(FlagsmithEvent) 412 | .then((res)=>{ 413 | try { 414 | this.evaluationEvent = JSON.parse(res!) || {} 415 | } catch (e) { 416 | this.evaluationEvent = {}; 417 | } 418 | this.analyticsInterval = setInterval(this.analyticsFlags, this.ticks!); 419 | }) 420 | } 421 | 422 | if (this.enableAnalytics) { 423 | if (this.analyticsInterval) { 424 | clearInterval(this.analyticsInterval); 425 | } 426 | 427 | if (AsyncStorage && this.canUseStorage) { 428 | AsyncStorage.getItem(FlagsmithEvent, (err, res) => { 429 | if (res && this.evaluationContext.environment) { 430 | const json = JSON.parse(res); 431 | if (json[this.evaluationContext.environment.apiKey]) { 432 | const state = this.getState(); 433 | this.log("Retrieved events from cache", res); 434 | this.setState({ 435 | ...state, 436 | evaluationEvent: json[this.evaluationContext.environment.apiKey], 437 | }); 438 | } 439 | } 440 | }); 441 | } 442 | } 443 | 444 | //If the user specified default flags emit a changed event immediately 445 | if (cacheFlags) { 446 | if (AsyncStorage && this.canUseStorage) { 447 | const onRetrievedStorage = async (error: Error | null, res: string | null) => { 448 | if (res) { 449 | let flagsChanged = null 450 | const traitsChanged = null 451 | try { 452 | const json = JSON.parse(res) as IState; 453 | let cachePopulated = false; 454 | let staleCachePopulated = false; 455 | if (json && json.api === this.api && json.evaluationContext?.environment?.apiKey === this.evaluationContext.environment?.apiKey) { 456 | let setState = true; 457 | if (this.evaluationContext.identity && (json.evaluationContext?.identity?.identifier !== this.evaluationContext.identity.identifier)) { 458 | this.log("Ignoring cache, identity has changed from " + json.evaluationContext?.identity?.identifier + " to " + this.evaluationContext.identity.identifier ) 459 | setState = false; 460 | } 461 | if (this.cacheOptions.ttl) { 462 | if (!json.ts || (new Date().valueOf() - json.ts > this.cacheOptions.ttl)) { 463 | if (json.ts && !this.cacheOptions.loadStale) { 464 | this.log("Ignoring cache, timestamp is too old ts:" + json.ts + " ttl: " + this.cacheOptions.ttl + " time elapsed since cache: " + (new Date().valueOf()-json.ts)+"ms") 465 | setState = false; 466 | } 467 | else if (json.ts && this.cacheOptions.loadStale) { 468 | this.log("Loading stale cache, timestamp ts:" + json.ts + " ttl: " + this.cacheOptions.ttl + " time elapsed since cache: " + (new Date().valueOf()-json.ts)+"ms") 469 | staleCachePopulated = true; 470 | setState = true; 471 | } 472 | } 473 | } 474 | if (setState) { 475 | cachePopulated = true; 476 | flagsChanged = getChanges(this.flags, json.flags) 477 | this.setState({ 478 | ...json, 479 | evaluationContext: toEvaluationContext({ 480 | ...json.evaluationContext, 481 | identity: json.evaluationContext?.identity ? { 482 | ...json.evaluationContext?.identity, 483 | traits: { 484 | // Traits passed in flagsmith.init will overwrite server values 485 | ...traits || {}, 486 | } 487 | } : undefined, 488 | }) 489 | }); 490 | this.log("Retrieved flags from cache", json); 491 | } 492 | } 493 | 494 | if (cachePopulated) { // retrieved flags from local storage 495 | // fetch the flags if the cache is stale, or if we're not skipping api on cache hits 496 | const shouldFetchFlags = !preventFetch && (!this.cacheOptions.skipAPI || staleCachePopulated) 497 | this._onChange(null, 498 | { isFromServer: false, flagsChanged, traitsChanged }, 499 | this._loadedState(null, FlagSource.CACHE, shouldFetchFlags) 500 | ); 501 | this.oldFlags = this.flags; 502 | if (this.cacheOptions.skipAPI && cachePopulated && !staleCachePopulated) { 503 | this.log("Skipping API, using cache") 504 | } 505 | if (shouldFetchFlags) { 506 | // We want to resolve init since we have cached flags 507 | 508 | this.getFlags().catch((error) => { 509 | this.onError?.(error) 510 | }) 511 | } 512 | } else { 513 | if (!preventFetch) { 514 | await this.getFlags(); 515 | } 516 | } 517 | } catch (e) { 518 | this.log("Exception fetching cached logs", e); 519 | throw e; 520 | } 521 | } else { 522 | if (!preventFetch) { 523 | await this.getFlags(); 524 | } else { 525 | if (defaultFlags) { 526 | this._onChange(null, 527 | { isFromServer: false, flagsChanged: getChanges({}, this.flags), traitsChanged: getChanges({}, this.evaluationContext.identity?.traits) }, 528 | this._loadedState(null, FlagSource.DEFAULT_FLAGS), 529 | ); 530 | } else if (this.flags) { // flags exist due to set state being called e.g. from nextJS serverState 531 | this._onChange(null, 532 | { isFromServer: false, flagsChanged: getChanges({}, this.flags), traitsChanged: getChanges({}, this.evaluationContext.identity?.traits) }, 533 | this._loadedState(null, FlagSource.DEFAULT_FLAGS), 534 | ); 535 | } else { 536 | throw new Error(WRONG_FLAGSMITH_CONFIG); 537 | } 538 | } 539 | } 540 | }; 541 | try { 542 | const res = AsyncStorage.getItemSync? AsyncStorage.getItemSync(this.getStorageKey()) : await AsyncStorage.getItem(this.getStorageKey()); 543 | await onRetrievedStorage(null, res) 544 | } catch (e) { 545 | // Only re-throw if we don't have fallback flags (defaultFlags or cached flags) 546 | if (!this.flags || Object.keys(this.flags).length === 0) { 547 | throw e; 548 | } 549 | // We have fallback flags, so call onError but don't reject init() 550 | const typedError = e instanceof Error ? e : new Error(`${e}`); 551 | this.onError?.(typedError); 552 | } 553 | } 554 | } else if (!preventFetch) { 555 | await this.getFlags(); 556 | } else { 557 | if (defaultFlags) { 558 | this._onChange(null, { isFromServer: false, flagsChanged: getChanges({}, defaultFlags), traitsChanged: getChanges({}, evaluationContext.identity?.traits) }, this._loadedState(null, FlagSource.DEFAULT_FLAGS)); 559 | } else if (this.flags) { 560 | let error = null; 561 | if (Object.keys(this.flags).length === 0) { 562 | error = WRONG_FLAGSMITH_CONFIG; 563 | } 564 | this._onChange(null, { isFromServer: false, flagsChanged: getChanges({}, this.flags), traitsChanged: getChanges({}, evaluationContext.identity?.traits) }, this._loadedState(error, FlagSource.DEFAULT_FLAGS)); 565 | if(error) { 566 | throw new Error(error) 567 | } 568 | } 569 | } 570 | } catch (error) { 571 | this.log('Error during initialisation ', error); 572 | const typedError = error instanceof Error ? error : new Error(`${error}`); 573 | this.onError?.(typedError); 574 | throw error; 575 | } 576 | } 577 | 578 | getAllFlags() { 579 | return this.flags; 580 | } 581 | 582 | identify(userId?: string | null, traits?: ITraits, transient?: boolean) { 583 | this.identity = userId 584 | this.evaluationContext.identity = { 585 | identifier: userId, 586 | transient: transient, 587 | // clear out old traits when switching identity 588 | traits: this.evaluationContext.identity && this.evaluationContext.identity.identifier == userId ? this.evaluationContext.identity.traits : {} 589 | } 590 | this.evaluationContext.identity.identifier = userId; 591 | this.log("Identify: " + this.evaluationContext.identity.identifier) 592 | 593 | if (traits) { 594 | this.evaluationContext.identity.traits = Object.fromEntries( 595 | Object.entries(traits).map( 596 | ([tKey, tValue]) => [tKey, isTraitEvaluationContext(tValue) ? tValue : {value: tValue}] 597 | ) 598 | ); 599 | } 600 | if (this.initialised) { 601 | return this.getFlags(); 602 | } 603 | return Promise.resolve(); 604 | } 605 | 606 | getState() { 607 | return { 608 | api: this.api, 609 | flags: this.flags, 610 | ts: this.ts, 611 | evaluationContext: this.evaluationContext, 612 | identity: this.identity, 613 | evaluationEvent: this.evaluationEvent, 614 | } as IState 615 | } 616 | 617 | setState(state: IState) { 618 | if (state) { 619 | this.initialised = true; 620 | this.api = state.api || this.api || defaultAPI; 621 | this.flags = state.flags || this.flags; 622 | this.evaluationContext = state.evaluationContext || this.evaluationContext, 623 | this.evaluationEvent = state.evaluationEvent || this.evaluationEvent; 624 | this.identity = this.getContext()?.identity?.identifier 625 | this.log("setState called", this) 626 | } 627 | } 628 | 629 | logout() { 630 | this.identity = null 631 | this.evaluationContext.identity = null; 632 | if (this.initialised) { 633 | return this.getFlags(); 634 | } 635 | return Promise.resolve(); 636 | } 637 | 638 | startListening(ticks = 1000) { 639 | if (this.getFlagInterval) { 640 | clearInterval(this.getFlagInterval); 641 | } 642 | this.getFlagInterval = setInterval(this.getFlags, ticks); 643 | } 644 | 645 | stopListening() { 646 | if (this.getFlagInterval) { 647 | clearInterval(this.getFlagInterval); 648 | this.getFlagInterval = null; 649 | } 650 | } 651 | 652 | getValue = (key: string, options?: GetValueOptions, skipAnalytics?: boolean) => { 653 | const flag = this.flags && this.flags[key.toLowerCase().replace(/ /g, '_')]; 654 | let res = null; 655 | if (flag) { 656 | res = flag.value; 657 | } 658 | 659 | if (!options?.skipAnalytics && !skipAnalytics) { 660 | this.evaluateFlag(key, "VALUE"); 661 | } 662 | 663 | if (res === null && typeof options?.fallback !== 'undefined') { 664 | return options.fallback; 665 | } 666 | 667 | if (options?.json) { 668 | try { 669 | if (res === null) { 670 | this.log("Tried to parse null flag as JSON: " + key); 671 | return null; 672 | } 673 | return JSON.parse(res as string); 674 | } catch (e) { 675 | return options.fallback; 676 | } 677 | } 678 | //todo record check for value 679 | return res; 680 | } 681 | 682 | getTrait = (key: string) => { 683 | return this.evaluationContext.identity?.traits && this.evaluationContext.identity.traits[key.toLowerCase().replace(/ /g, '_')]?.value; 684 | } 685 | 686 | getAllTraits = () => { 687 | return Object.fromEntries( 688 | Object.entries(this.evaluationContext.identity?.traits || {}).map( 689 | ([tKey, tContext]) => [tKey, tContext?.value] 690 | ) 691 | ); 692 | } 693 | 694 | setContext = (clientEvaluationContext: ClientEvaluationContext) => { 695 | const evaluationContext = toEvaluationContext(clientEvaluationContext); 696 | this.evaluationContext = { 697 | ...evaluationContext, 698 | environment: evaluationContext.environment || this.evaluationContext.environment, 699 | }; 700 | this.identity = this.getContext()?.identity?.identifier 701 | 702 | if (this.initialised) { 703 | return this.getFlags(); 704 | } 705 | 706 | return Promise.resolve(); 707 | } 708 | 709 | getContext = () => { 710 | return this.evaluationContext; 711 | } 712 | 713 | updateContext = (evaluationContext: ClientEvaluationContext) => { 714 | return this.setContext({ 715 | ...this.getContext(), 716 | ...evaluationContext, 717 | }) 718 | } 719 | 720 | setTrait = (key: string, trait_value: IFlagsmithTrait) => { 721 | const { api } = this; 722 | 723 | if (!api) { 724 | return 725 | } 726 | 727 | return this.setContext({ 728 | ...this.evaluationContext, 729 | identity: { 730 | ...this.evaluationContext.identity, 731 | traits: { 732 | ...this.evaluationContext.identity?.traits, 733 | ...toTraitEvaluationContextObject(Object.fromEntries( 734 | [[key, trait_value]], 735 | )) 736 | } 737 | } 738 | }); 739 | }; 740 | 741 | setTraits = (traits: ITraits) => { 742 | 743 | if (!this.api) { 744 | console.error(initError("setTraits")) 745 | return 746 | } 747 | 748 | return this.setContext({ 749 | ...this.evaluationContext, 750 | identity: { 751 | ...this.evaluationContext.identity, 752 | traits: { 753 | ...this.evaluationContext.identity?.traits, 754 | ...Object.fromEntries( 755 | Object.entries(traits).map( 756 | (([tKey, tValue]) => [tKey, isTraitEvaluationContext(tValue) ? tValue : {value: tValue}]) 757 | ) 758 | ) 759 | } 760 | } 761 | }); 762 | }; 763 | 764 | hasFeature = (key: string, options?: HasFeatureOptions) => { 765 | // Support legacy skipAnalytics boolean parameter 766 | const usingNewOptions = typeof options === 'object' 767 | const flag = this.flags && this.flags[key.toLowerCase().replace(/ /g, '_')]; 768 | let res = false; 769 | if (!flag && usingNewOptions && typeof options.fallback !== 'undefined') { 770 | res = options?.fallback 771 | } else if (flag && flag.enabled) { 772 | res = true; 773 | } 774 | if ((usingNewOptions && !options.skipAnalytics) || !options) { 775 | this.evaluateFlag(key, "ENABLED"); 776 | } 777 | if(this.sentryClient) { 778 | try { 779 | this.sentryClient.getIntegrationByName( 780 | "FeatureFlags", 781 | )?.addFeatureFlag?.(key, res); 782 | } catch (e) { 783 | console.error(e) 784 | } 785 | } 786 | 787 | return res; 788 | }; 789 | 790 | private _loadedState(error: any = null, source: FlagSource, isFetching = false) { 791 | return { 792 | error, 793 | isFetching, 794 | isLoading: false, 795 | source 796 | } 797 | } 798 | 799 | private getStorageKey = ()=> { 800 | return this.cacheOptions?.storageKey || DEFAULT_FLAGSMITH_KEY + "_" + this.evaluationContext.environment?.apiKey 801 | } 802 | 803 | private log(...args: (unknown)[]) { 804 | if (this.enableLogs) { 805 | console.log.apply(this, ['FLAGSMITH:', new Date().valueOf() - (this.timer || 0), 'ms', ...args]); 806 | } 807 | } 808 | 809 | private updateStorage() { 810 | if (this.cacheFlags) { 811 | this.ts = new Date().valueOf(); 812 | const state = JSON.stringify(this.getState()); 813 | this.log('Setting storage', state); 814 | AsyncStorage!.setItem(this.getStorageKey(), state); 815 | } 816 | } 817 | 818 | private getJSON = (url: string, method?: 'GET' | 'POST' | 'PUT', body?: string) => { 819 | const { headers } = this; 820 | const options: RequestOptions = { 821 | method: method || 'GET', 822 | body, 823 | // @ts-ignore next-js overrides fetch 824 | cache: 'no-cache', 825 | headers: {}, 826 | }; 827 | if (this.evaluationContext.environment) 828 | options.headers['X-Environment-Key'] = this.evaluationContext.environment.apiKey; 829 | if (method && method !== 'GET') 830 | options.headers['Content-Type'] = 'application/json; charset=utf-8'; 831 | 832 | 833 | if (this.applicationMetadata?.name) { 834 | options.headers['Flagsmith-Application-Name'] = this.applicationMetadata.name; 835 | } 836 | 837 | if (this.applicationMetadata?.version) { 838 | options.headers['Flagsmith-Application-Version'] = this.applicationMetadata.version; 839 | } 840 | 841 | if (SDK_VERSION) { 842 | options.headers['Flagsmith-SDK-User-Agent'] = `flagsmith-js-sdk/${SDK_VERSION}` 843 | } 844 | 845 | if (headers) { 846 | Object.assign(options.headers, headers); 847 | } 848 | 849 | if (!_fetch) { 850 | console.error('Flagsmith: fetch is undefined, please specify a fetch implementation into flagsmith.init to support SSR.'); 851 | } 852 | 853 | const requestedIdentity = `${this.evaluationContext.identity?.identifier}`; 854 | return _fetch(url, options) 855 | .then(res => { 856 | const newIdentity = `${this.evaluationContext.identity?.identifier}`; 857 | if (requestedIdentity !== newIdentity) { 858 | this.log(`Received response with identity mismatch, ignoring response. Requested: ${requestedIdentity}, Current: ${newIdentity}`); 859 | return; 860 | } 861 | const lastUpdated = res.headers?.get('x-flagsmith-document-updated-at'); 862 | if (lastUpdated) { 863 | try { 864 | const lastUpdatedFloat = parseFloat(lastUpdated); 865 | if (isNaN(lastUpdatedFloat)) { 866 | return Promise.reject('Failed to parse x-flagsmith-document-updated-at'); 867 | } 868 | this.timestamp = lastUpdatedFloat; 869 | } catch (e) { 870 | this.log(e, 'Failed to parse x-flagsmith-document-updated-at', lastUpdated); 871 | } 872 | } 873 | this.log('Fetch response: ' + res.status + ' ' + (method || 'GET') + +' ' + url); 874 | return res.text!() 875 | .then((text) => { 876 | let err = text; 877 | try { 878 | err = JSON.parse(text); 879 | } catch (e) {} 880 | if(!err && res.status) { 881 | err = `API Response: ${res.status}` 882 | } 883 | return res.status && res.status >= 200 && res.status < 300 ? err : Promise.reject(new Error(err)); 884 | }); 885 | }); 886 | }; 887 | 888 | private updateEventStorage() { 889 | if (this.enableAnalytics) { 890 | const events = JSON.stringify(this.getState().evaluationEvent); 891 | AsyncStorage!.setItem(FlagsmithEvent, events) 892 | .catch((e) => console.error("Flagsmith: Error setting item in async storage", e)); 893 | } 894 | } 895 | 896 | private evaluateFlag =(key: string, method: 'VALUE' | 'ENABLED') => { 897 | if (this.datadogRum) { 898 | if (!this.datadogRum!.client!.addFeatureFlagEvaluation) { 899 | console.error('Flagsmith: Your datadog RUM client does not support the function addFeatureFlagEvaluation, please update it.'); 900 | } else { 901 | if (method === 'VALUE') { 902 | this.datadogRum!.client!.addFeatureFlagEvaluation(FLAGSMITH_CONFIG_ANALYTICS_KEY + key, this.getValue(key, {}, true)); 903 | } else { 904 | this.datadogRum!.client!.addFeatureFlagEvaluation(FLAGSMITH_FLAG_ANALYTICS_KEY + key, this.hasFeature(key, true)); 905 | } 906 | } 907 | } 908 | 909 | if (this.enableAnalytics) { 910 | if (!this.evaluationEvent || !this.evaluationContext.environment) return; 911 | if (!this.evaluationEvent[this.evaluationContext.environment.apiKey]) { 912 | this.evaluationEvent[this.evaluationContext.environment.apiKey] = {}; 913 | } 914 | if (this.evaluationEvent[this.evaluationContext.environment.apiKey][key] === undefined) { 915 | this.evaluationEvent[this.evaluationContext.environment.apiKey][key] = 0; 916 | } 917 | this.evaluationEvent[this.evaluationContext.environment.apiKey][key] += 1; 918 | } 919 | this.updateEventStorage(); 920 | }; 921 | 922 | private setLoadingState(loadingState: LoadingState) { 923 | if (!deepEqual(loadingState, this.loadingState)) { 924 | this.loadingState = { ...loadingState }; 925 | this.log('Loading state changed', loadingState); 926 | this._triggerLoadingState?.(); 927 | } 928 | } 929 | 930 | private _onChange: OnChange = (previousFlags, params, loadingState) => { 931 | this.setLoadingState(loadingState); 932 | this.onChange?.(previousFlags, params, this.loadingState); 933 | this._trigger?.(); 934 | }; 935 | 936 | private setupRealtime(eventSourceUrl: string, environmentID: string) { 937 | const connectionUrl = eventSourceUrl + 'sse/environments/' + environmentID + '/stream'; 938 | if (!eventSource) { 939 | this.log('Error, EventSource is undefined'); 940 | } else if (!this.eventSource) { 941 | this.log('Creating event source with url ' + connectionUrl); 942 | this.eventSource = new eventSource(connectionUrl); 943 | this.eventSource.addEventListener('environment_updated', (e) => { 944 | let updated_at; 945 | try { 946 | const data = JSON.parse(e.data); 947 | updated_at = data.updated_at; 948 | } catch (e) { 949 | this.log('Could not parse sse event', e); 950 | } 951 | if (!updated_at) { 952 | this.log('No updated_at received, fetching flags', e); 953 | } else if (!this.timestamp || updated_at > this.timestamp) { 954 | if (this.isLoading) { 955 | this.log('updated_at is new, but flags are loading', e.data, this.timestamp); 956 | } else { 957 | this.log('updated_at is new, fetching flags', e.data, this.timestamp); 958 | this.getFlags(); 959 | } 960 | } else { 961 | this.log('updated_at is outdated, skipping get flags', e.data, this.timestamp); 962 | } 963 | }); 964 | } 965 | } 966 | }; 967 | 968 | export default function({ fetch, AsyncStorage, eventSource }: Config): IFlagsmith { 969 | return new Flagsmith({ fetch, AsyncStorage, eventSource }) as IFlagsmith; 970 | } 971 | --------------------------------------------------------------------------------