flagsmith-nodejs 5.1.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/sdk/types.ts CHANGED
@@ -3,6 +3,7 @@ import { EnvironmentModel } from '../flagsmith-engine/index.js';
3
3
  import { Dispatcher } from 'undici-types';
4
4
  import { Logger } from 'pino';
5
5
  import { BaseOfflineHandler } from './offline_handlers.js';
6
+ import { Flagsmith } from './index.js'
6
7
 
7
8
  export type IFlagsmithValue<T = string | number | boolean | null> = T;
8
9
 
@@ -27,22 +28,92 @@ export interface FlagsmithCache {
27
28
 
28
29
  export type Fetch = typeof fetch
29
30
 
31
+ /**
32
+ * The configuration options for a {@link Flagsmith} client.
33
+ */
30
34
  export interface FlagsmithConfig {
35
+ /**
36
+ * The environment's client-side or server-side key.
37
+ */
31
38
  environmentKey?: string;
39
+ /**
40
+ * The Flagsmith API URL. Set this if you are not using Flagsmith's public service, i.e. https://app.flagsmith.com.
41
+ *
42
+ * @default https://edge.api.flagsmith.com/api/v1/
43
+ */
32
44
  apiUrl?: string;
45
+ /**
46
+ * A custom {@link Dispatcher} to use when making HTTP requests.
47
+ */
33
48
  agent?: Dispatcher;
49
+ /**
50
+ * A custom {@link fetch} implementation to use when making HTTP requests.
51
+ */
34
52
  fetch?: Fetch;
35
- customHeaders?: { [key: string]: any };
53
+ /**
54
+ * Custom headers to use in all HTTP requests.
55
+ */
56
+ customHeaders?: HeadersInit
57
+ /**
58
+ * The network request timeout duration, in seconds.
59
+ *
60
+ * @default 10
61
+ */
36
62
  requestTimeoutSeconds?: number;
63
+ /**
64
+ * The amount of time, in milliseconds, to wait before retrying failed network requests.
65
+ */
66
+ requestRetryDelayMilliseconds?: number;
67
+ /**
68
+ * If enabled, flags are evaluated locally using the environment state cached in memory.
69
+ *
70
+ * The client will lazily fetch the environment from the Flagsmith API, and poll it every {@link environmentRefreshIntervalSeconds}.
71
+ */
37
72
  enableLocalEvaluation?: boolean;
73
+ /**
74
+ * The time, in seconds, to wait before refreshing the cached environment state.
75
+ * @default 60
76
+ */
38
77
  environmentRefreshIntervalSeconds?: number;
78
+ /**
79
+ * How many times to retry any failed network request before giving up.
80
+ * @default 3
81
+ */
39
82
  retries?: number;
83
+ /**
84
+ * If enabled, the client will keep track of any flags evaluated using {@link Flags.isFeatureEnabled},
85
+ * {@link Flags.getFeatureValue} or {@link Flags.getFlag}, and periodically flush this data to the Flagsmith API.
86
+ */
40
87
  enableAnalytics?: boolean;
41
- defaultFlagHandler?: (featureName: string) => DefaultFlag;
88
+ /**
89
+ * Used to return fallback values for flags when evaluation fails for any reason. If not provided and flag
90
+ * evaluation fails, an error will be thrown intsead.
91
+ *
92
+ * @param flagKey The key of the flag that failed to evaluate.
93
+ *
94
+ * @example
95
+ * // All flags disabled and with no value by default
96
+ * const defaultHandler = () => new DefaultFlag(undefined, false)
97
+ *
98
+ * // Enable only VIP flags by default
99
+ * const vipDefaultHandler = (key: string) => new Default(undefined, key.startsWith('vip_'))
100
+ */
101
+ defaultFlagHandler?: (flagKey: string) => DefaultFlag;
42
102
  cache?: FlagsmithCache;
43
- onEnvironmentChange?: (error: Error | null, result: EnvironmentModel) => void;
103
+ /**
104
+ * A callback function to invoke whenever the cached environment is updated.
105
+ * @param error The error that occurred when the environment state failed to update, if any.
106
+ * @param result The updated environment state, if no error was thrown.
107
+ */
108
+ onEnvironmentChange?: (error: Error | null, result?: EnvironmentModel) => void;
44
109
  logger?: Logger;
110
+ /**
111
+ * If enabled, the client will work offline and not make any network requests. Requires {@link offlineHandler}.
112
+ */
45
113
  offlineMode?: boolean;
114
+ /**
115
+ * If {@link offlineMode} is enabled, this handler is used to calculate the values of all flags.
116
+ */
46
117
  offlineHandler?: BaseOfflineHandler;
47
118
  }
48
119
 
package/sdk/utils.ts CHANGED
@@ -46,25 +46,59 @@ export const retryFetch = (
46
46
  fetchOptions: RequestInit & { dispatcher?: Dispatcher },
47
47
  retries: number = 3,
48
48
  timeoutMs: number = 10, // set an overall timeout for this function
49
+ retryDelayMs: number = 1000,
49
50
  customFetch: Fetch,
50
51
  ): Promise<Response> => {
51
- return new Promise((resolve, reject) => {
52
- const retryWrapper = (n: number) => {
53
- customFetch(url, {
52
+ const retryWrapper = async (n: number): Promise<Response> => {
53
+ try {
54
+ return await customFetch(url, {
54
55
  ...fetchOptions,
55
56
  signal: AbortSignal.timeout(timeoutMs)
56
- })
57
- .then(res => resolve(res))
58
- .catch(async err => {
59
- if (n > 0) {
60
- await delay(1000);
61
- retryWrapper(--n);
62
- } else {
63
- reject(err);
64
- }
65
57
  });
66
- };
67
-
68
- retryWrapper(retries);
69
- });
58
+ } catch (e) {
59
+ if (n > 0) {
60
+ await delay(retryDelayMs);
61
+ return await retryWrapper(n - 1);
62
+ } else {
63
+ throw e;
64
+ }
65
+ }
66
+ };
67
+ return retryWrapper(retries);
70
68
  };
69
+
70
+ /**
71
+ * A deferred promise can be resolved or rejected outside its creation scope.
72
+ *
73
+ * @template T The type of the value that the deferred promise will resolve to.
74
+ *
75
+ * @example
76
+ * const deferred = new Deferred<string>()
77
+ *
78
+ * // Pass the promise somewhere
79
+ * performAsyncOperation(deferred.promise)
80
+ *
81
+ * // Resolve it when ready from anywhere
82
+ * deferred.resolve("Operation completed")
83
+ * deferred.failed("Error")
84
+ */
85
+ export class Deferred<T> {
86
+ public readonly promise: Promise<T>;
87
+ private resolvePromise!: (value: T | PromiseLike<T>) => void;
88
+ private rejectPromise!: (reason?: unknown) => void;
89
+
90
+ constructor(initial?: T) {
91
+ this.promise = new Promise<T>((resolve, reject) => {
92
+ this.resolvePromise = resolve;
93
+ this.rejectPromise = reject;
94
+ });
95
+ }
96
+
97
+ public resolve(value: T | PromiseLike<T>): void {
98
+ this.resolvePromise(value);
99
+ }
100
+
101
+ public reject(reason?: unknown): void {
102
+ this.rejectPromise(reason);
103
+ }
104
+ }
@@ -1,9 +1,5 @@
1
1
  import {analyticsProcessor, fetch} from './utils.js';
2
2
 
3
- afterEach(() => {
4
- vi.resetAllMocks();
5
- });
6
-
7
3
  test('test_analytics_processor_track_feature_updates_analytics_data', () => {
8
4
  const aP = analyticsProcessor();
9
5
  aP.trackFeature("myFeature");
@@ -1,12 +1,6 @@
1
1
  import { fetch, environmentJSON, environmentModel, flagsJSON, flagsmith, identitiesJSON, TestCache } from './utils.js';
2
2
 
3
- beforeEach(() => {
4
- vi.clearAllMocks();
5
- });
6
-
7
3
  test('test_empty_cache_not_read_but_populated', async () => {
8
- fetch.mockResolvedValue(new Response(flagsJSON));
9
-
10
4
  const cache = new TestCache();
11
5
  const set = vi.spyOn(cache, 'set');
12
6
 
@@ -23,8 +17,6 @@ test('test_empty_cache_not_read_but_populated', async () => {
23
17
  });
24
18
 
25
19
  test('test_api_not_called_when_cache_present', async () => {
26
- fetch.mockResolvedValue(new Response(flagsJSON));
27
-
28
20
  const cache = new TestCache();
29
21
  const set = vi.spyOn(cache, 'set');
30
22
 
@@ -56,27 +48,25 @@ test('test_api_called_twice_when_no_cache', async () => {
56
48
  });
57
49
 
58
50
  test('test_get_environment_flags_uses_local_environment_when_available', async () => {
59
- fetch.mockResolvedValue(new Response(flagsJSON));
60
-
61
51
  const cache = new TestCache();
62
52
  const set = vi.spyOn(cache, 'set');
63
53
 
64
- const flg = flagsmith({ cache });
54
+ const flg = flagsmith({ cache, environmentKey: 'ser.key', enableLocalEvaluation: true });
65
55
  const model = environmentModel(JSON.parse(environmentJSON));
66
- flg.environment = model;
56
+ const getEnvironment = vi.spyOn(flg, 'getEnvironment')
57
+ getEnvironment.mockResolvedValue(model)
67
58
 
68
- const allFlags = await (await flg.getEnvironmentFlags()).allFlags();
59
+ const allFlags = (await flg.getEnvironmentFlags()).allFlags();
69
60
 
70
61
  expect(set).toBeCalled();
71
62
  expect(fetch).toBeCalledTimes(0);
63
+ expect(getEnvironment).toBeCalledTimes(1);
72
64
  expect(allFlags[0].enabled).toBe(model.featureStates[0].enabled);
73
65
  expect(allFlags[0].value).toBe(model.featureStates[0].getValue());
74
66
  expect(allFlags[0].featureName).toBe(model.featureStates[0].feature.name);
75
67
  });
76
68
 
77
69
  test('test_cache_used_for_identity_flags', async () => {
78
- fetch.mockResolvedValue(new Response(identitiesJSON));
79
-
80
70
  const cache = new TestCache();
81
71
  const set = vi.spyOn(cache, 'set');
82
72
 
@@ -98,8 +88,6 @@ test('test_cache_used_for_identity_flags', async () => {
98
88
  });
99
89
 
100
90
  test('test_cache_used_for_identity_flags_local_evaluation', async () => {
101
- fetch.mockResolvedValue(new Response(environmentJSON));
102
-
103
91
  const cache = new TestCache();
104
92
  const set = vi.spyOn(cache, 'set');
105
93
 
@@ -4,13 +4,7 @@ import { DefaultFlag } from '../../sdk/models.js';
4
4
 
5
5
  vi.mock('../../sdk/polling_manager');
6
6
 
7
- beforeEach(() => {
8
- vi.clearAllMocks();
9
- });
10
-
11
7
  test('test_get_environment_flags_calls_api_when_no_local_environment', async () => {
12
- fetch.mockResolvedValue(new Response(flagsJSON));
13
-
14
8
  const flg = flagsmith();
15
9
  const allFlags = await (await flg.getEnvironmentFlags()).allFlags();
16
10
 
@@ -20,20 +14,6 @@ test('test_get_environment_flags_calls_api_when_no_local_environment', async ()
20
14
  expect(allFlags[0].featureName).toBe('some_feature');
21
15
  });
22
16
 
23
- test('test_get_environment_flags_uses_local_environment_when_available', async () => {
24
- fetch.mockResolvedValue(new Response(flagsJSON));
25
-
26
- const flg = flagsmith();
27
- const model = environmentModel(JSON.parse(environmentJSON));
28
- flg.environment = model;
29
-
30
- const allFlags = await (await flg.getEnvironmentFlags()).allFlags();
31
- expect(fetch).toBeCalledTimes(0);
32
- expect(allFlags[0].enabled).toBe(model.featureStates[0].enabled);
33
- expect(allFlags[0].value).toBe(model.featureStates[0].getValue());
34
- expect(allFlags[0].featureName).toBe(model.featureStates[0].feature.name);
35
- });
36
-
37
17
  test('test_default_flag_is_used_when_no_environment_flags_returned', async () => {
38
18
  fetch.mockResolvedValue(new Response(JSON.stringify([])));
39
19
 
@@ -57,8 +37,6 @@ test('test_default_flag_is_used_when_no_environment_flags_returned', async () =>
57
37
  });
58
38
 
59
39
  test('test_analytics_processor_tracks_flags', async () => {
60
- fetch.mockResolvedValue(new Response(flagsJSON));
61
-
62
40
  const defaultFlag = new DefaultFlag('some-default-value', true);
63
41
 
64
42
  const defaultFlagHandler = (featureName: string) => defaultFlag;
@@ -78,8 +56,6 @@ test('test_analytics_processor_tracks_flags', async () => {
78
56
  });
79
57
 
80
58
  test('test_getFeatureValue', async () => {
81
- fetch.mockResolvedValue(new Response(flagsJSON));
82
-
83
59
  const defaultFlag = new DefaultFlag('some-default-value', true);
84
60
 
85
61
  const defaultFlagHandler = (featureName: string) => defaultFlag;
@@ -106,7 +82,7 @@ test('test_throws_when_no_default_flag_handler_after_multiple_API_errors', async
106
82
  await expect(async () => {
107
83
  const flags = await flg.getEnvironmentFlags();
108
84
  const flag = flags.getFlag('some_feature');
109
- }).rejects.toThrow('Error during fetching the API response');
85
+ }).rejects.toThrow('getEnvironmentFlags failed and no default flag handler was provided');
110
86
  });
111
87
 
112
88
  test('test_non_200_response_raises_flagsmith_api_error', async () => {
@@ -122,8 +98,6 @@ test('test_non_200_response_raises_flagsmith_api_error', async () => {
122
98
  await expect(flg.getEnvironmentFlags()).rejects.toThrow();
123
99
  });
124
100
  test('test_default_flag_is_not_used_when_environment_flags_returned', async () => {
125
- fetch.mockResolvedValue(new Response(flagsJSON));
126
-
127
101
  const defaultFlag = new DefaultFlag('some-default-value', true);
128
102
 
129
103
  const defaultFlagHandler = (featureName: string) => defaultFlag;
@@ -161,8 +135,6 @@ test('test_default_flag_is_used_when_bad_api_response_happens', async () => {
161
135
  });
162
136
 
163
137
  test('test_local_evaluation', async () => {
164
- fetch.mockResolvedValue(new Response(environmentJSON));
165
-
166
138
  const defaultFlag = new DefaultFlag('some-default-value', true);
167
139
 
168
140
  const defaultFlagHandler = (featureName: string) => defaultFlag;
@@ -1,16 +1,18 @@
1
1
  import Flagsmith from '../../sdk/index.js';
2
- import { fetch, environmentJSON, flagsmith, identitiesJSON, identityWithTransientTraitsJSON, transientIdentityJSON } from './utils.js';
2
+ import {
3
+ fetch,
4
+ environmentJSON,
5
+ flagsmith,
6
+ identitiesJSON,
7
+ identityWithTransientTraitsJSON,
8
+ transientIdentityJSON,
9
+ badFetch
10
+ } from './utils.js';
3
11
  import { DefaultFlag } from '../../sdk/models.js';
4
12
 
5
13
  vi.mock('../../sdk/polling_manager');
6
14
 
7
- beforeEach(() => {
8
- vi.clearAllMocks();
9
- });
10
-
11
-
12
15
  test('test_get_identity_flags_calls_api_when_no_local_environment_no_traits', async () => {
13
- fetch.mockResolvedValue(new Response(identitiesJSON));
14
16
  const identifier = 'identifier';
15
17
 
16
18
  const flg = flagsmith();
@@ -23,7 +25,6 @@ test('test_get_identity_flags_calls_api_when_no_local_environment_no_traits', as
23
25
  });
24
26
 
25
27
  test('test_get_identity_flags_uses_environment_when_local_environment_no_traits', async () => {
26
- fetch.mockResolvedValue(new Response(environmentJSON))
27
28
  const identifier = 'identifier';
28
29
 
29
30
  const flg = flagsmith({
@@ -40,7 +41,6 @@ test('test_get_identity_flags_uses_environment_when_local_environment_no_traits'
40
41
  });
41
42
 
42
43
  test('test_get_identity_flags_calls_api_when_no_local_environment_with_traits', async () => {
43
- fetch.mockResolvedValue(new Response(identitiesJSON))
44
44
  const identifier = 'identifier';
45
45
  const traits = { some_trait: 'some_value' };
46
46
  const flg = flagsmith();
@@ -53,8 +53,6 @@ test('test_get_identity_flags_calls_api_when_no_local_environment_with_traits',
53
53
  });
54
54
 
55
55
  test('test_default_flag_is_not_used_when_identity_flags_returned', async () => {
56
- fetch.mockResolvedValue(new Response(identitiesJSON))
57
-
58
56
  const defaultFlag = new DefaultFlag('some-default-value', true);
59
57
 
60
58
  const defaultFlagHandler = (featureName: string) => defaultFlag;
@@ -125,7 +123,6 @@ test('test_default_flag_is_used_when_no_identity_flags_returned_and_no_custom_de
125
123
  expect(flag.enabled).toBe(false);
126
124
  });
127
125
 
128
-
129
126
  test('test_get_identity_flags_multivariate_value_with_local_evaluation_enabled', async () => {
130
127
  fetch.mockResolvedValue(new Response(environmentJSON));
131
128
  const identifier = 'identifier';
@@ -133,7 +130,6 @@ test('test_get_identity_flags_multivariate_value_with_local_evaluation_enabled',
133
130
  const flg = flagsmith({
134
131
  environmentKey: 'ser.key',
135
132
  enableLocalEvaluation: true,
136
-
137
133
  });
138
134
 
139
135
  const identityFlags = (await flg.getIdentityFlags(identifier))
@@ -170,10 +166,10 @@ test('test_transient_identity', async () => {
170
166
  test('test_identity_with_transient_traits', async () => {
171
167
  fetch.mockResolvedValue(new Response(identityWithTransientTraitsJSON));
172
168
  const identifier = 'transient_trait_identifier';
173
- const traits = {
169
+ const traits = {
174
170
  some_trait: 'some_value',
175
171
  another_trait: {value: 'another_value', transient: true},
176
- explicitly_non_transient_trait: {value: 'non_transient_value', transient: false}
172
+ explicitly_non_transient_trait: {value: 'non_transient_value', transient: false}
177
173
  }
178
174
  const traitsInRequest = [
179
175
  {
@@ -206,3 +202,12 @@ test('test_identity_with_transient_traits', async () => {
206
202
  expect(identityFlags[0].value).toBe('some-identity-with-transient-trait-value');
207
203
  expect(identityFlags[0].featureName).toBe('some_feature');
208
204
  });
205
+
206
+ test('getIdentityFlags fails if API call failed and no default flag handler was provided', async () => {
207
+ const flg = flagsmith({
208
+ fetch: badFetch,
209
+ })
210
+ await expect(flg.getIdentityFlags('user'))
211
+ .rejects
212
+ .toThrow('getIdentityFlags failed and no default flag handler was provided')
213
+ })