flipflag-sdk 1.1.21 → 1.1.23

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/dist/next.js CHANGED
@@ -8,6 +8,9 @@ class FlipFlagSDK {
8
8
  this.cache = new Map();
9
9
  this.defaultBaseUrl = "https://app.flipflag.ru";
10
10
  this.stateListeners = new Set();
11
+ this.maxListeners = 100; // Maximum number of listeners to prevent memory leaks
12
+ // Validate required configuration
13
+ this.validateConfig(config);
11
14
  this.config = {
12
15
  baseUrl: this.defaultBaseUrl,
13
16
  cacheTimeout: 30000, // 30 seconds
@@ -32,9 +35,18 @@ class FlipFlagSDK {
32
35
  * Identify user for A/B testing and user-specific flags
33
36
  */
34
37
  identifyUser(options) {
38
+ if (!options || typeof options !== "object") {
39
+ throw new Error("options must be an object");
40
+ }
41
+ if (!options.userId || typeof options.userId !== "string") {
42
+ throw new Error("userId is required and must be a string");
43
+ }
44
+ if (options.userProperties && typeof options.userProperties !== "object") {
45
+ throw new Error("userProperties must be an object");
46
+ }
35
47
  this.config.userId = options.userId;
36
48
  // Clear A/B test cache since user changed
37
- this.clearABTestCache();
49
+ this.clearAllABTestCache();
38
50
  // If we have a user, we might want to refresh immediately
39
51
  if (this.config.pullInterval && this.config.pullInterval > 0) {
40
52
  this.pullData();
@@ -50,6 +62,9 @@ class FlipFlagSDK {
50
62
  * Subscribe to state changes
51
63
  */
52
64
  subscribe(callback) {
65
+ if (this.stateListeners.size >= this.maxListeners) {
66
+ console.warn(`Maximum number of listeners (${this.maxListeners}) reached. This may cause memory leaks.`);
67
+ }
53
68
  this.stateListeners.add(callback);
54
69
  // Return unsubscribe function
55
70
  return () => {
@@ -160,6 +175,8 @@ class FlipFlagSDK {
160
175
  }
161
176
  catch (error) {
162
177
  console.error("State listener error:", error);
178
+ // Remove faulty listener to prevent repeated errors
179
+ this.stateListeners.delete(callback);
163
180
  }
164
181
  });
165
182
  }
@@ -196,9 +213,9 @@ class FlipFlagSDK {
196
213
  }
197
214
  }
198
215
  /**
199
- * Clear A/B test cache
216
+ * Clear all A/B test cache (private method)
200
217
  */
201
- clearABTestCache() {
218
+ clearAllABTestCache() {
202
219
  const abTestKeys = Array.from(this.cache.keys()).filter((key) => key.startsWith("abtest:"));
203
220
  abTestKeys.forEach((key) => this.cache.delete(key));
204
221
  }
@@ -224,6 +241,15 @@ class FlipFlagSDK {
224
241
  */
225
242
  async getFlag(flagName, projectId = this.config.projectId, environment) {
226
243
  var _a, _b;
244
+ if (!flagName || typeof flagName !== "string") {
245
+ throw new Error("flagName is required and must be a string");
246
+ }
247
+ if (!projectId || typeof projectId !== "string") {
248
+ throw new Error("projectId must be a string");
249
+ }
250
+ if (environment && typeof environment !== "string") {
251
+ throw new Error("environment must be a string");
252
+ }
227
253
  // If we have the flag in state, return it immediately
228
254
  if (this.state.flags[flagName] !== undefined) {
229
255
  return {
@@ -235,20 +261,18 @@ class FlipFlagSDK {
235
261
  };
236
262
  }
237
263
  // Check if we're currently loading data (pullData in progress)
238
- // In this case, wait a bit and check state again
264
+ // In this case, wait for loading to complete using Promise
239
265
  if (this.state.isLoading) {
240
- // Wait up to 5 seconds for loading to complete
241
- for (let i = 0; i < 50; i++) {
242
- await new Promise((resolve) => setTimeout(resolve, 100));
243
- if (!this.state.isLoading && this.state.flags[flagName] !== undefined) {
244
- return {
245
- projectId: this.config.projectId,
246
- flagName,
247
- environment: this.config.environment || "all",
248
- value: this.state.flags[flagName],
249
- timestamp: ((_b = this.state.lastFetch) === null || _b === void 0 ? void 0 : _b.toISOString()) || new Date().toISOString(),
250
- };
251
- }
266
+ await this.waitForLoadingComplete();
267
+ // After waiting, check state again
268
+ if (this.state.flags[flagName] !== undefined) {
269
+ return {
270
+ projectId: this.config.projectId,
271
+ flagName,
272
+ environment: this.config.environment || "all",
273
+ value: this.state.flags[flagName],
274
+ timestamp: ((_b = this.state.lastFetch) === null || _b === void 0 ? void 0 : _b.toISOString()) || new Date().toISOString(),
275
+ };
252
276
  }
253
277
  }
254
278
  const cacheKey = `flag:${projectId}:${flagName}:${environment || "all"}`;
@@ -264,10 +288,76 @@ class FlipFlagSDK {
264
288
  this.setCache(cacheKey, response, this.config.cacheTimeout);
265
289
  return response;
266
290
  }
291
+ /**
292
+ * Validate configuration
293
+ */
294
+ validateConfig(config) {
295
+ if (!config.apiKey || typeof config.apiKey !== "string") {
296
+ throw new Error("apiKey is required and must be a string");
297
+ }
298
+ if (!config.projectId || typeof config.projectId !== "string") {
299
+ throw new Error("projectId is required and must be a string");
300
+ }
301
+ if (config.baseUrl && typeof config.baseUrl !== "string") {
302
+ throw new Error("baseUrl must be a string");
303
+ }
304
+ if (config.environment && typeof config.environment !== "string") {
305
+ throw new Error("environment must be a string");
306
+ }
307
+ if (config.userId && typeof config.userId !== "string") {
308
+ throw new Error("userId must be a string");
309
+ }
310
+ if (config.cacheTimeout &&
311
+ (typeof config.cacheTimeout !== "number" || config.cacheTimeout < 0)) {
312
+ throw new Error("cacheTimeout must be a non-negative number");
313
+ }
314
+ if (config.retryAttempts &&
315
+ (typeof config.retryAttempts !== "number" || config.retryAttempts < 0)) {
316
+ throw new Error("retryAttempts must be a non-negative number");
317
+ }
318
+ if (config.retryDelay &&
319
+ (typeof config.retryDelay !== "number" || config.retryDelay < 0)) {
320
+ throw new Error("retryDelay must be a non-negative number");
321
+ }
322
+ if (config.pullInterval &&
323
+ (typeof config.pullInterval !== "number" || config.pullInterval < 0)) {
324
+ throw new Error("pullInterval must be a non-negative number");
325
+ }
326
+ }
327
+ /**
328
+ * Wait for loading to complete with timeout
329
+ */
330
+ async waitForLoadingComplete(timeout = 5000) {
331
+ const startTime = Date.now();
332
+ return new Promise((resolve, reject) => {
333
+ const checkLoading = () => {
334
+ if (!this.state.isLoading) {
335
+ resolve();
336
+ return;
337
+ }
338
+ if (Date.now() - startTime > timeout) {
339
+ reject(new Error("Timeout waiting for SDK loading to complete"));
340
+ return;
341
+ }
342
+ // Check again after a short delay
343
+ setTimeout(checkLoading, 50);
344
+ };
345
+ checkLoading();
346
+ });
347
+ }
267
348
  /**
268
349
  * Get A/B test variant for a user
269
350
  */
270
351
  async getAbTestVariant(testName, userId, projectId = this.config.projectId) {
352
+ if (!testName || typeof testName !== "string") {
353
+ throw new Error("testName is required and must be a string");
354
+ }
355
+ if (!userId || typeof userId !== "string") {
356
+ throw new Error("userId is required and must be a string");
357
+ }
358
+ if (!projectId || typeof projectId !== "string") {
359
+ throw new Error("projectId must be a string");
360
+ }
271
361
  // If we have the A/B test in state, return it immediately
272
362
  const stateVariant = this.state.abTests[testName];
273
363
  if (stateVariant && stateVariant.testName === testName) {
@@ -331,6 +421,34 @@ class FlipFlagSDK {
331
421
  this.cache.clear();
332
422
  }
333
423
  }
424
+ /**
425
+ * Clear cache for specific flag
426
+ */
427
+ clearFlagCache(flagName, environment) {
428
+ const cacheKey = `flag:${this.config.projectId}:${flagName}:${environment || "all"}`;
429
+ this.cache.delete(cacheKey);
430
+ }
431
+ /**
432
+ * Clear cache for all flags
433
+ */
434
+ clearFlagsCache(environment) {
435
+ const cacheKey = `flags:${this.config.projectId}:${environment || "all"}`;
436
+ this.cache.delete(cacheKey);
437
+ }
438
+ /**
439
+ * Clear cache for A/B test variant
440
+ */
441
+ clearABTestCache(testName, userId) {
442
+ if (testName && userId) {
443
+ const cacheKey = `abtest:${this.config.projectId}:${testName}:${userId}`;
444
+ this.cache.delete(cacheKey);
445
+ }
446
+ else {
447
+ // Clear all A/B test cache
448
+ const abTestKeys = Array.from(this.cache.keys()).filter((key) => key.startsWith("abtest:"));
449
+ abTestKeys.forEach((key) => this.cache.delete(key));
450
+ }
451
+ }
334
452
  /**
335
453
  * Update configuration
336
454
  */
@@ -483,32 +601,38 @@ function useFeatureFlag(flagName, options = {}) {
483
601
  finally {
484
602
  setLoading(false);
485
603
  }
486
- }, [
487
- sdk,
488
- flagName,
489
- options.fallbackValue,
490
- options.enabled,
491
- shouldSkipInitialFetch,
492
- ]);
604
+ }, [sdk, flagName, options.fallbackValue, options.enabled]);
493
605
  const refetch = react.useCallback(async () => {
494
606
  sdk.clearCache(`flag:${sdk["config"].projectId}:${flagName}:${sdk["config"].environment || "all"}`);
495
607
  await fetchFlag();
496
608
  }, [sdk, flagName, fetchFlag]);
497
609
  // Subscribe to SDK state changes for automatic updates via pullInterval
498
610
  react.useEffect(() => {
611
+ let lastFetchTime = null;
499
612
  const unsubscribe = sdk.subscribe((state) => {
500
- // Only update if this is from pullInterval and has flag data
501
- if (state.lastFetch && state.flags[flagName] !== undefined) {
613
+ // Only update if this is a new fetch and has flag data for this specific flag
614
+ if (state.lastFetch &&
615
+ state.lastFetch !== lastFetchTime &&
616
+ state.flags[flagName] !== undefined) {
617
+ lastFetchTime = state.lastFetch;
502
618
  setValue(state.flags[flagName]);
503
619
  setLoading(false);
504
- setError(null);
620
+ setError(state.error);
505
621
  }
506
622
  });
507
623
  return unsubscribe;
508
624
  }, [sdk, flagName]);
625
+ // Effect to handle initial fetch and server-side state changes
509
626
  react.useEffect(() => {
510
- fetchFlag();
511
- }, [fetchFlag]);
627
+ // Only fetch if we don't have server-side data or if server-side data becomes invalid
628
+ if (!shouldSkipInitialFetch) {
629
+ fetchFlag();
630
+ }
631
+ else {
632
+ // If we have server-side data, just set loading to false
633
+ setLoading(false);
634
+ }
635
+ }, [shouldSkipInitialFetch, fetchFlag]);
512
636
  return {
513
637
  value,
514
638
  loading,
@@ -577,18 +701,16 @@ function useFeatureFlags(flagNames, options = {}) {
577
701
  finally {
578
702
  setLoading(false);
579
703
  }
580
- }, [
581
- sdk,
582
- memoizedFlagNames,
583
- memoizedFallbackValues,
584
- options.enabled,
585
- shouldSkipInitialFetch,
586
- ]);
704
+ }, [sdk, memoizedFlagNames, memoizedFallbackValues, options.enabled]);
587
705
  // Subscribe to SDK state changes for automatic updates via pullInterval
588
706
  react.useEffect(() => {
707
+ let lastFetchTime = null;
589
708
  const unsubscribe = sdk.subscribe((state) => {
590
- // Only update if this is from pullInterval (has lastFetch) and has flag data
591
- if (state.lastFetch && Object.keys(state.flags).length > 0) {
709
+ // Only update if this is a new fetch (different from last one) and has flag data
710
+ if (state.lastFetch &&
711
+ state.lastFetch !== lastFetchTime &&
712
+ Object.keys(state.flags).length > 0) {
713
+ lastFetchTime = state.lastFetch;
592
714
  const newFlags = {};
593
715
  memoizedFlagNames.forEach((flagName) => {
594
716
  var _a;
@@ -602,18 +724,32 @@ function useFeatureFlags(flagNames, options = {}) {
602
724
  });
603
725
  setFlags(newFlags);
604
726
  setLoading(false);
605
- setError(null);
727
+ setError(state.error);
606
728
  }
607
729
  });
608
730
  return unsubscribe;
609
731
  }, [sdk, memoizedFlagNames, memoizedFallbackValues]);
732
+ // Effect to handle initial fetch and server-side state changes
610
733
  react.useEffect(() => {
611
- fetchFlags();
612
- }, [fetchFlags]);
734
+ // Only fetch if we don't have server-side data or if server-side data becomes invalid
735
+ if (!shouldSkipInitialFetch) {
736
+ fetchFlags();
737
+ }
738
+ else {
739
+ // If we have server-side data, just set loading to false
740
+ setLoading(false);
741
+ }
742
+ }, [shouldSkipInitialFetch, fetchFlags]);
743
+ const refetch = react.useCallback(async () => {
744
+ // Clear cache for flags
745
+ sdk.clearCache(`flags:${sdk["config"].projectId}:${sdk["config"].environment || "all"}`);
746
+ await fetchFlags();
747
+ }, [sdk, fetchFlags]);
613
748
  return {
614
749
  flags,
615
750
  loading,
616
751
  error,
752
+ refetch,
617
753
  };
618
754
  }
619
755
 
@@ -644,7 +780,7 @@ function useABTest(testName, options) {
644
780
  finally {
645
781
  setLoading(false);
646
782
  }
647
- }, [sdk, testName, options.userId, options.enabled]);
783
+ }, [sdk, testName, options.userId]);
648
784
  const recordEvent = react.useCallback(async (event, eventData) => {
649
785
  if (!variantId) {
650
786
  console.warn("Cannot record event: no variant assigned yet");
@@ -659,21 +795,33 @@ function useABTest(testName, options) {
659
795
  }, [sdk, testName, options.userId, variantId]);
660
796
  // Subscribe to SDK state changes for automatic updates via pullInterval
661
797
  react.useEffect(() => {
798
+ let lastFetchTime = null;
662
799
  const unsubscribe = sdk.subscribe((state) => {
663
- // Only update if this is from pullInterval and has A/B test data
664
- if (state.lastFetch && state.abTests[testName]) {
800
+ if (!options.enabled && options.enabled !== undefined) {
801
+ return;
802
+ }
803
+ // Only update if this is a new fetch and has A/B test data
804
+ if (state.lastFetch &&
805
+ state.lastFetch !== lastFetchTime &&
806
+ state.abTests[testName]) {
807
+ lastFetchTime = state.lastFetch;
665
808
  const newABTest = state.abTests[testName];
666
- setVariant(newABTest.variant.value);
667
- setVariantId(newABTest.variantId);
668
- setLoading(false);
669
- setError(null);
809
+ if (newABTest.variantId !== variantId) {
810
+ setVariant(newABTest.variant.value);
811
+ setVariantId(newABTest.variantId);
812
+ setLoading(false);
813
+ setError(state.error);
814
+ }
670
815
  }
671
816
  });
672
817
  return unsubscribe;
673
- }, [sdk, testName]);
818
+ }, [sdk, testName, options.enabled, variantId]);
819
+ // Initial fetch effect
674
820
  react.useEffect(() => {
675
- fetchVariant();
676
- }, [fetchVariant]);
821
+ if (options.enabled !== false) {
822
+ fetchVariant();
823
+ }
824
+ }, [options.enabled, fetchVariant]);
677
825
  return {
678
826
  variant,
679
827
  variantId,
@@ -686,20 +834,8 @@ function useABTest(testName, options) {
686
834
  /**
687
835
  * Fetch feature flags on the server side for SSR
688
836
  */
689
- async function getServerSideFlags(configOrFlagNames, flagNames) {
690
- let config;
691
- let flagList;
692
- // Handle different calling patterns
693
- if (Array.isArray(configOrFlagNames)) {
694
- // Called with flagNames only - this should not happen in normal usage
695
- // We'll need to get config from somewhere else, but for now throw error
696
- throw new Error("getServerSideFlags must be called with config when used outside of FlipflagProvider context");
697
- }
698
- else if (configOrFlagNames) {
699
- config = configOrFlagNames;
700
- flagList = flagNames;
701
- }
702
- else {
837
+ async function getServerSideFlags(config, flagNames) {
838
+ if (!config) {
703
839
  throw new Error("getServerSideFlags requires config parameter");
704
840
  }
705
841
  const sdk = new FlipFlagSDK({
@@ -711,31 +847,16 @@ async function getServerSideFlags(configOrFlagNames, flagNames) {
711
847
  });
712
848
  try {
713
849
  const response = await sdk.getFlags();
714
- let flags = response.flags;
715
- // If specific flag names are requested, filter the response
716
- if (flagList && flagList.length > 0) {
717
- flags = {};
718
- flagList.forEach((flagName) => {
719
- var _a;
720
- flags[flagName] = (_a = response.flags[flagName]) !== null && _a !== void 0 ? _a : false;
721
- });
722
- }
723
850
  return {
724
- flags,
851
+ flags: response.flags,
725
852
  timestamp: Date.now(),
726
853
  };
727
854
  }
728
855
  catch (error) {
729
856
  console.error("Failed to fetch server-side flags:", error);
730
- // Return fallback values
731
- const fallbackFlags = {};
732
- if (flagList) {
733
- flagList.forEach((flagName) => {
734
- fallbackFlags[flagName] = false;
735
- });
736
- }
857
+ // Return empty flags object on error
737
858
  return {
738
- flags: fallbackFlags,
859
+ flags: {},
739
860
  timestamp: Date.now(),
740
861
  };
741
862
  }