> = () => {
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 |
--------------------------------------------------------------------------------