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