@schematichq/schematic-vue 0.1.0 → 1.2.16

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.
@@ -754,7 +754,7 @@ function contextString(context) {
754
754
  }, {});
755
755
  return JSON.stringify(sortedContext);
756
756
  }
757
- var version = "1.2.6";
757
+ var version = "1.2.16";
758
758
  var anonymousIdKey = "schematicId";
759
759
  var Schematic = class {
760
760
  additionalHeaders = {};
@@ -776,6 +776,30 @@ var Schematic = class {
776
776
  checks = {};
777
777
  featureUsageEventMap = {};
778
778
  webSocketUrl = "wss://api.schematichq.com";
779
+ webSocketConnectionTimeout = 1e4;
780
+ webSocketReconnect = true;
781
+ webSocketMaxReconnectAttempts = 7;
782
+ // Max attempts after connection disrupted
783
+ webSocketMaxConnectionAttempts = 3;
784
+ // Max attempts for initial connection
785
+ webSocketInitialRetryDelay = 1e3;
786
+ webSocketMaxRetryDelay = 3e4;
787
+ wsReconnectAttempts = 0;
788
+ wsReconnectTimer = null;
789
+ wsIntentionalDisconnect = false;
790
+ currentWebSocket = null;
791
+ isConnecting = false;
792
+ maxEventQueueSize = 100;
793
+ // Prevent memory issues with very long network outages
794
+ maxEventRetries = 5;
795
+ // Maximum retry attempts for failed events
796
+ eventRetryInitialDelay = 1e3;
797
+ // Initial retry delay in ms
798
+ eventRetryMaxDelay = 3e4;
799
+ // Maximum retry delay in ms
800
+ retryTimer = null;
801
+ flagValueDefaults = {};
802
+ flagCheckDefaults = {};
779
803
  constructor(apiKey, options) {
780
804
  this.apiKey = apiKey;
781
805
  this.eventQueue = [];
@@ -807,8 +831,13 @@ var Schematic = class {
807
831
  };
808
832
  if (options?.storage) {
809
833
  this.storage = options.storage;
810
- } else if (typeof localStorage !== "undefined") {
811
- this.storage = localStorage;
834
+ } else {
835
+ try {
836
+ if (typeof localStorage !== "undefined") {
837
+ this.storage = localStorage;
838
+ }
839
+ } catch {
840
+ }
812
841
  }
813
842
  if (options?.apiUrl !== void 0) {
814
843
  this.apiUrl = options.apiUrl;
@@ -819,11 +848,54 @@ var Schematic = class {
819
848
  if (options?.webSocketUrl !== void 0) {
820
849
  this.webSocketUrl = options.webSocketUrl;
821
850
  }
851
+ if (options?.webSocketConnectionTimeout !== void 0) {
852
+ this.webSocketConnectionTimeout = options.webSocketConnectionTimeout;
853
+ }
854
+ if (options?.webSocketReconnect !== void 0) {
855
+ this.webSocketReconnect = options.webSocketReconnect;
856
+ }
857
+ if (options?.webSocketMaxReconnectAttempts !== void 0) {
858
+ this.webSocketMaxReconnectAttempts = options.webSocketMaxReconnectAttempts;
859
+ }
860
+ if (options?.webSocketInitialRetryDelay !== void 0) {
861
+ this.webSocketInitialRetryDelay = options.webSocketInitialRetryDelay;
862
+ }
863
+ if (options?.webSocketMaxRetryDelay !== void 0) {
864
+ this.webSocketMaxRetryDelay = options.webSocketMaxRetryDelay;
865
+ }
866
+ if (options?.maxEventQueueSize !== void 0) {
867
+ this.maxEventQueueSize = options.maxEventQueueSize;
868
+ }
869
+ if (options?.maxEventRetries !== void 0) {
870
+ this.maxEventRetries = options.maxEventRetries;
871
+ }
872
+ if (options?.eventRetryInitialDelay !== void 0) {
873
+ this.eventRetryInitialDelay = options.eventRetryInitialDelay;
874
+ }
875
+ if (options?.eventRetryMaxDelay !== void 0) {
876
+ this.eventRetryMaxDelay = options.eventRetryMaxDelay;
877
+ }
878
+ if (options?.flagValueDefaults !== void 0) {
879
+ this.flagValueDefaults = options.flagValueDefaults;
880
+ }
881
+ if (options?.flagCheckDefaults !== void 0) {
882
+ this.flagCheckDefaults = options.flagCheckDefaults;
883
+ }
822
884
  if (typeof window !== "undefined" && window?.addEventListener) {
823
885
  window.addEventListener("beforeunload", () => {
824
886
  this.flushEventQueue();
825
887
  this.flushContextDependentEventQueue();
826
888
  });
889
+ if (this.useWebSocket) {
890
+ window.addEventListener("offline", () => {
891
+ this.debug("Browser went offline, closing WebSocket connection");
892
+ this.handleNetworkOffline();
893
+ });
894
+ window.addEventListener("online", () => {
895
+ this.debug("Browser came online, attempting to reconnect WebSocket");
896
+ this.handleNetworkOnline();
897
+ });
898
+ }
827
899
  }
828
900
  if (this.offlineEnabled) {
829
901
  this.debug(
@@ -833,6 +905,66 @@ var Schematic = class {
833
905
  this.debug("Initialized with debug mode enabled");
834
906
  }
835
907
  }
908
+ /**
909
+ * Resolve fallback value according to priority order:
910
+ * 1. Callsite fallback value (if provided)
911
+ * 2. Boolean value from flagCheckDefaults initialization option
912
+ * 3. Boolean value from flagValueDefaults initialization option
913
+ * 4. Default to false
914
+ */
915
+ resolveFallbackValue(key, callsiteFallback) {
916
+ if (callsiteFallback !== void 0) {
917
+ return callsiteFallback;
918
+ }
919
+ if (key in this.flagCheckDefaults) {
920
+ return this.flagCheckDefaults[key].value;
921
+ }
922
+ if (key in this.flagValueDefaults) {
923
+ return this.flagValueDefaults[key];
924
+ }
925
+ return false;
926
+ }
927
+ /**
928
+ * Resolve complete CheckFlagReturn object according to priority order:
929
+ * 1. Use callsite fallback for boolean value, construct CheckFlagReturn
930
+ * 2. Use flagCheckDefaults if available for this flag
931
+ * 3. Use flagValueDefaults if available for this flag, construct CheckFlagReturn
932
+ * 4. Default CheckFlagReturn with value: false
933
+ */
934
+ resolveFallbackCheckFlagReturn(key, callsiteFallback, reason = "Fallback value used", error) {
935
+ if (callsiteFallback !== void 0) {
936
+ return {
937
+ flag: key,
938
+ value: callsiteFallback,
939
+ reason,
940
+ error
941
+ };
942
+ }
943
+ if (key in this.flagCheckDefaults) {
944
+ const defaultReturn = this.flagCheckDefaults[key];
945
+ return {
946
+ ...defaultReturn,
947
+ flag: key,
948
+ // Ensure flag matches the requested key
949
+ reason: error !== void 0 ? reason : defaultReturn.reason,
950
+ error
951
+ };
952
+ }
953
+ if (key in this.flagValueDefaults) {
954
+ return {
955
+ flag: key,
956
+ value: this.flagValueDefaults[key],
957
+ reason,
958
+ error
959
+ };
960
+ }
961
+ return {
962
+ flag: key,
963
+ value: false,
964
+ reason,
965
+ error
966
+ };
967
+ }
836
968
  /**
837
969
  * Get value for a single flag.
838
970
  * In WebSocket mode, returns cached values if connection is active, otherwise establishes
@@ -841,16 +973,21 @@ var Schematic = class {
841
973
  * In REST mode, makes an API call for each check.
842
974
  */
843
975
  async checkFlag(options) {
844
- const { fallback = false, key } = options;
976
+ const { fallback, key } = options;
845
977
  const context = options.context || this.context;
846
978
  const contextStr = contextString(context);
847
979
  this.debug(`checkFlag: ${key}`, { context, fallback });
848
980
  if (this.isOffline()) {
981
+ const resolvedFallbackResult = this.resolveFallbackCheckFlagReturn(
982
+ key,
983
+ fallback,
984
+ "Offline mode - using initialization defaults"
985
+ );
849
986
  this.debug(`checkFlag offline result: ${key}`, {
850
- value: fallback,
987
+ value: resolvedFallbackResult.value,
851
988
  offlineMode: true
852
989
  });
853
- return fallback;
990
+ return resolvedFallbackResult.value;
854
991
  }
855
992
  if (!this.useWebSocket) {
856
993
  const requestUrl = `${this.apiUrl}/flags/${key}/check`;
@@ -877,15 +1014,15 @@ var Schematic = class {
877
1014
  this.submitFlagCheckEvent(key, result, context);
878
1015
  return result.value;
879
1016
  }).catch((error) => {
880
- console.error("There was a problem with the fetch operation:", error);
881
- const errorResult = {
882
- flag: key,
883
- value: fallback,
884
- reason: "API request failed",
885
- error: error instanceof Error ? error.message : String(error)
886
- };
1017
+ console.warn("There was a problem with the fetch operation:", error);
1018
+ const errorResult = this.resolveFallbackCheckFlagReturn(
1019
+ key,
1020
+ fallback,
1021
+ "API request failed",
1022
+ error instanceof Error ? error.message : String(error)
1023
+ );
887
1024
  this.submitFlagCheckEvent(key, errorResult, context);
888
- return fallback;
1025
+ return errorResult.value;
889
1026
  });
890
1027
  }
891
1028
  try {
@@ -895,12 +1032,12 @@ var Schematic = class {
895
1032
  return existingVals[key].value;
896
1033
  }
897
1034
  if (this.isOffline()) {
898
- return fallback;
1035
+ return this.resolveFallbackValue(key, fallback);
899
1036
  }
900
1037
  try {
901
1038
  await this.setContext(context);
902
1039
  } catch (error) {
903
- console.error(
1040
+ console.warn(
904
1041
  "WebSocket connection failed, falling back to REST:",
905
1042
  error
906
1043
  );
@@ -908,10 +1045,10 @@ var Schematic = class {
908
1045
  }
909
1046
  const contextVals = this.checks[contextStr] ?? {};
910
1047
  const flagCheck = contextVals[key];
911
- const result = flagCheck?.value ?? fallback;
1048
+ const result = flagCheck?.value ?? this.resolveFallbackValue(key, fallback);
912
1049
  this.debug(
913
1050
  `checkFlag WebSocket result: ${key}`,
914
- typeof flagCheck !== "undefined" ? flagCheck : { value: fallback, fallbackUsed: true }
1051
+ typeof flagCheck !== "undefined" ? flagCheck : { value: result, fallbackUsed: true }
915
1052
  );
916
1053
  if (typeof flagCheck !== "undefined") {
917
1054
  this.submitFlagCheckEvent(key, flagCheck, context);
@@ -919,14 +1056,14 @@ var Schematic = class {
919
1056
  return result;
920
1057
  } catch (error) {
921
1058
  console.error("Unexpected error in checkFlag:", error);
922
- const errorResult = {
923
- flag: key,
924
- value: fallback,
925
- reason: "Unexpected error in flag check",
926
- error: error instanceof Error ? error.message : String(error)
927
- };
1059
+ const errorResult = this.resolveFallbackCheckFlagReturn(
1060
+ key,
1061
+ fallback,
1062
+ "Unexpected error in flag check",
1063
+ error instanceof Error ? error.message : String(error)
1064
+ );
928
1065
  this.submitFlagCheckEvent(key, errorResult, context);
929
- return fallback;
1066
+ return errorResult.value;
930
1067
  }
931
1068
  }
932
1069
  /**
@@ -938,6 +1075,49 @@ var Schematic = class {
938
1075
  console.log(`[Schematic] ${message}`, ...args);
939
1076
  }
940
1077
  }
1078
+ /**
1079
+ * Create a persistent message handler for websocket flag updates
1080
+ */
1081
+ createPersistentMessageHandler(context) {
1082
+ return (event) => {
1083
+ const message = JSON.parse(event.data);
1084
+ this.debug(`WebSocket persistent message received:`, message);
1085
+ if (!(contextString(context) in this.checks)) {
1086
+ this.checks[contextString(context)] = {};
1087
+ }
1088
+ (message.flags ?? []).forEach((flag) => {
1089
+ const flagCheck = CheckFlagReturnFromJSON(flag);
1090
+ const contextStr = contextString(context);
1091
+ if (this.checks[contextStr] === void 0) {
1092
+ this.checks[contextStr] = {};
1093
+ }
1094
+ this.checks[contextStr][flagCheck.flag] = flagCheck;
1095
+ this.debug(`WebSocket flag update:`, {
1096
+ flag: flagCheck.flag,
1097
+ value: flagCheck.value,
1098
+ flagCheck
1099
+ });
1100
+ if (typeof flagCheck.featureUsageEvent === "string") {
1101
+ this.updateFeatureUsageEventMap(flagCheck);
1102
+ }
1103
+ if ((this.flagCheckListeners[flag.flag]?.size ?? 0) > 0 || (this.flagValueListeners[flag.flag]?.size ?? 0) > 0) {
1104
+ this.submitFlagCheckEvent(flagCheck.flag, flagCheck, context);
1105
+ }
1106
+ this.debug(`About to notify listeners for flag ${flag.flag}`, {
1107
+ flag: flag.flag,
1108
+ value: flagCheck.value
1109
+ });
1110
+ this.notifyFlagCheckListeners(flag.flag, flagCheck);
1111
+ this.notifyFlagValueListeners(flag.flag, flagCheck.value);
1112
+ this.debug(`Finished notifying listeners for flag ${flag.flag}`, {
1113
+ flag: flag.flag,
1114
+ value: flagCheck.value
1115
+ });
1116
+ });
1117
+ this.flushContextDependentEventQueue();
1118
+ this.setIsPending(false);
1119
+ };
1120
+ }
941
1121
  /**
942
1122
  * Helper function to check if client is in offline mode
943
1123
  */
@@ -969,11 +1149,12 @@ var Schematic = class {
969
1149
  */
970
1150
  async fallbackToRest(key, context, fallback) {
971
1151
  if (this.isOffline()) {
1152
+ const resolvedFallback = this.resolveFallbackValue(key, fallback);
972
1153
  this.debug(`fallbackToRest offline result: ${key}`, {
973
- value: fallback,
1154
+ value: resolvedFallback,
974
1155
  offlineMode: true
975
1156
  });
976
- return fallback;
1157
+ return resolvedFallback;
977
1158
  }
978
1159
  try {
979
1160
  const requestUrl = `${this.apiUrl}/flags/${key}/check`;
@@ -999,15 +1180,15 @@ var Schematic = class {
999
1180
  this.submitFlagCheckEvent(key, result, context);
1000
1181
  return result.value;
1001
1182
  } catch (error) {
1002
- console.error("REST API call failed, using fallback value:", error);
1003
- const errorResult = {
1004
- flag: key,
1005
- value: fallback,
1006
- reason: "API request failed (fallback)",
1007
- error: error instanceof Error ? error.message : String(error)
1008
- };
1183
+ console.warn("REST API call failed, using fallback value:", error);
1184
+ const errorResult = this.resolveFallbackCheckFlagReturn(
1185
+ key,
1186
+ fallback,
1187
+ "API request failed (fallback)",
1188
+ error instanceof Error ? error.message : String(error)
1189
+ );
1009
1190
  this.submitFlagCheckEvent(key, errorResult, context);
1010
- return fallback;
1191
+ return errorResult.value;
1011
1192
  }
1012
1193
  }
1013
1194
  /**
@@ -1048,7 +1229,7 @@ var Schematic = class {
1048
1229
  {}
1049
1230
  );
1050
1231
  }).catch((error) => {
1051
- console.error("There was a problem with the fetch operation:", error);
1232
+ console.warn("There was a problem with the fetch operation:", error);
1052
1233
  return {};
1053
1234
  });
1054
1235
  };
@@ -1059,14 +1240,12 @@ var Schematic = class {
1059
1240
  */
1060
1241
  identify = (body) => {
1061
1242
  this.debug(`identify:`, body);
1062
- try {
1063
- this.setContext({
1064
- company: body.company?.keys,
1065
- user: body.keys
1066
- });
1067
- } catch (error) {
1068
- console.error("Error setting context:", error);
1069
- }
1243
+ this.setContext({
1244
+ company: body.company?.keys,
1245
+ user: body.keys
1246
+ }).catch((error) => {
1247
+ console.warn("Error setting context:", error);
1248
+ });
1070
1249
  return this.handleEvent("identify", body);
1071
1250
  };
1072
1251
  /**
@@ -1088,12 +1267,42 @@ var Schematic = class {
1088
1267
  try {
1089
1268
  this.setIsPending(true);
1090
1269
  if (!this.conn) {
1091
- this.conn = this.wsConnect();
1270
+ if (this.isConnecting) {
1271
+ this.debug(
1272
+ `Connection already in progress, waiting for it to complete`
1273
+ );
1274
+ while (this.isConnecting && this.conn === null) {
1275
+ await new Promise((resolve) => setTimeout(resolve, 10));
1276
+ }
1277
+ if (this.conn !== null) {
1278
+ const socket2 = await this.conn;
1279
+ await this.wsSendMessage(socket2, context);
1280
+ return;
1281
+ }
1282
+ }
1283
+ if (this.wsReconnectTimer !== null) {
1284
+ this.debug(
1285
+ `Cancelling scheduled reconnection, connecting immediately`
1286
+ );
1287
+ clearTimeout(this.wsReconnectTimer);
1288
+ this.wsReconnectTimer = null;
1289
+ }
1290
+ this.isConnecting = true;
1291
+ try {
1292
+ this.conn = this.wsConnect();
1293
+ const socket2 = await this.conn;
1294
+ this.isConnecting = false;
1295
+ await this.wsSendMessage(socket2, context);
1296
+ return;
1297
+ } catch (error) {
1298
+ this.isConnecting = false;
1299
+ throw error;
1300
+ }
1092
1301
  }
1093
1302
  const socket = await this.conn;
1094
1303
  await this.wsSendMessage(socket, context);
1095
1304
  } catch (error) {
1096
- console.error("Failed to establish WebSocket connection:", error);
1305
+ console.warn("Failed to establish WebSocket connection:", error);
1097
1306
  throw error;
1098
1307
  }
1099
1308
  };
@@ -1217,11 +1426,57 @@ var Schematic = class {
1217
1426
  }
1218
1427
  }
1219
1428
  };
1220
- flushEventQueue = () => {
1221
- while (this.eventQueue.length > 0) {
1222
- const event = this.eventQueue.shift();
1223
- if (event) {
1224
- this.sendEvent(event);
1429
+ startRetryTimer = () => {
1430
+ if (this.retryTimer !== null) {
1431
+ return;
1432
+ }
1433
+ this.retryTimer = setInterval(() => {
1434
+ this.flushEventQueue().catch((error) => {
1435
+ this.debug("Error in retry timer flush:", error);
1436
+ });
1437
+ if (this.eventQueue.length === 0) {
1438
+ this.stopRetryTimer();
1439
+ }
1440
+ }, 5e3);
1441
+ this.debug("Started retry timer");
1442
+ };
1443
+ stopRetryTimer = () => {
1444
+ if (this.retryTimer !== null) {
1445
+ clearInterval(this.retryTimer);
1446
+ this.retryTimer = null;
1447
+ this.debug("Stopped retry timer");
1448
+ }
1449
+ };
1450
+ flushEventQueue = async () => {
1451
+ if (this.eventQueue.length === 0) {
1452
+ return;
1453
+ }
1454
+ const now = Date.now();
1455
+ const readyEvents = [];
1456
+ const notReadyEvents = [];
1457
+ for (const event of this.eventQueue) {
1458
+ if (event.next_retry_at === void 0 || event.next_retry_at <= now) {
1459
+ readyEvents.push(event);
1460
+ } else {
1461
+ notReadyEvents.push(event);
1462
+ }
1463
+ }
1464
+ if (readyEvents.length === 0) {
1465
+ this.debug(
1466
+ `No events ready for retry yet (${notReadyEvents.length} still in backoff)`
1467
+ );
1468
+ return;
1469
+ }
1470
+ this.debug(
1471
+ `Flushing event queue: ${readyEvents.length} ready, ${notReadyEvents.length} waiting`
1472
+ );
1473
+ this.eventQueue = notReadyEvents;
1474
+ for (const event of readyEvents) {
1475
+ try {
1476
+ await this.sendEvent(event);
1477
+ this.debug(`Queued event sent successfully:`, event.type);
1478
+ } catch (error) {
1479
+ this.debug(`Failed to send queued event:`, error);
1225
1480
  }
1226
1481
  }
1227
1482
  };
@@ -1269,12 +1524,47 @@ var Schematic = class {
1269
1524
  },
1270
1525
  body: payload
1271
1526
  });
1527
+ if (!response.ok) {
1528
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
1529
+ }
1272
1530
  this.debug(`event sent:`, {
1273
1531
  status: response.status,
1274
1532
  statusText: response.statusText
1275
1533
  });
1276
1534
  } catch (error) {
1277
- console.error("Error sending Schematic event: ", error);
1535
+ const retryCount = (event.retry_count ?? 0) + 1;
1536
+ if (retryCount <= this.maxEventRetries) {
1537
+ this.debug(
1538
+ `Event failed to send (attempt ${retryCount}/${this.maxEventRetries}), queueing for retry:`,
1539
+ error
1540
+ );
1541
+ const baseDelay = this.eventRetryInitialDelay * Math.pow(2, retryCount - 1);
1542
+ const jitterDelay = Math.min(baseDelay, this.eventRetryMaxDelay);
1543
+ const nextRetryAt = Date.now() + jitterDelay;
1544
+ const retryEvent = {
1545
+ ...event,
1546
+ retry_count: retryCount,
1547
+ next_retry_at: nextRetryAt
1548
+ };
1549
+ if (this.eventQueue.length < this.maxEventQueueSize) {
1550
+ this.eventQueue.push(retryEvent);
1551
+ this.debug(
1552
+ `Event queued for retry in ${jitterDelay}ms (${this.eventQueue.length}/${this.maxEventQueueSize})`
1553
+ );
1554
+ } else {
1555
+ this.debug(
1556
+ `Event queue full (${this.maxEventQueueSize}), dropping oldest event`
1557
+ );
1558
+ this.eventQueue.shift();
1559
+ this.eventQueue.push(retryEvent);
1560
+ }
1561
+ this.startRetryTimer();
1562
+ } else {
1563
+ this.debug(
1564
+ `Event failed permanently after ${this.maxEventRetries} attempts, dropping:`,
1565
+ error
1566
+ );
1567
+ }
1278
1568
  }
1279
1569
  return Promise.resolve();
1280
1570
  };
@@ -1285,6 +1575,130 @@ var Schematic = class {
1285
1575
  /**
1286
1576
  * Websocket management
1287
1577
  */
1578
+ /**
1579
+ * Force an immediate WebSocket reconnection.
1580
+ * This is useful when the application returns from a background state (e.g., mobile app
1581
+ * coming back to foreground) and wants to immediately re-establish the connection
1582
+ * rather than waiting for the exponential backoff timer.
1583
+ *
1584
+ * This method will:
1585
+ * - Cancel any pending reconnection timer
1586
+ * - Reset the reconnection attempt counter
1587
+ * - Close any existing connection
1588
+ * - Immediately attempt to reconnect
1589
+ * - Re-send the current context to get fresh flag values
1590
+ *
1591
+ * Use this when you need guaranteed fresh values (e.g., after an in-app purchase).
1592
+ *
1593
+ * @example
1594
+ * ```typescript
1595
+ * // React Native example: reconnect when app comes to foreground
1596
+ * useEffect(() => {
1597
+ * const subscription = AppState.addEventListener("change", (state) => {
1598
+ * if (state === "active") {
1599
+ * client.forceReconnect();
1600
+ * }
1601
+ * });
1602
+ * return () => subscription.remove();
1603
+ * }, [client]);
1604
+ * ```
1605
+ */
1606
+ forceReconnect = async () => {
1607
+ return this.reconnect({ force: true });
1608
+ };
1609
+ /**
1610
+ * Reconnect the WebSocket connection only if the current connection is unhealthy.
1611
+ * This is useful when the application returns from a background state and wants to
1612
+ * ensure a healthy connection exists, but doesn't need to force a reconnection if
1613
+ * the connection is still active.
1614
+ *
1615
+ * This method will:
1616
+ * - Check if an existing connection is healthy (readyState === OPEN)
1617
+ * - If healthy, return immediately without reconnecting
1618
+ * - If unhealthy, perform the same reconnection logic as forceReconnect()
1619
+ *
1620
+ * Use this when you want efficient reconnection that avoids unnecessary disconnects.
1621
+ *
1622
+ * @example
1623
+ * ```typescript
1624
+ * // React Native example: reconnect only if needed when app comes to foreground
1625
+ * useEffect(() => {
1626
+ * const subscription = AppState.addEventListener("change", (state) => {
1627
+ * if (state === "active") {
1628
+ * client.reconnectIfNeeded();
1629
+ * }
1630
+ * });
1631
+ * return () => subscription.remove();
1632
+ * }, [client]);
1633
+ * ```
1634
+ */
1635
+ reconnectIfNeeded = async () => {
1636
+ return this.reconnect({ force: false });
1637
+ };
1638
+ /**
1639
+ * Internal method to handle reconnection logic for both forceReconnect and reconnectIfNeeded.
1640
+ */
1641
+ reconnect = async (options) => {
1642
+ const { force } = options;
1643
+ const methodName = force ? "forceReconnect" : "reconnectIfNeeded";
1644
+ if (this.isOffline()) {
1645
+ this.debug(`${methodName}: skipped (offline mode)`);
1646
+ return Promise.resolve();
1647
+ }
1648
+ if (!force && this.conn !== null) {
1649
+ try {
1650
+ const existingSocket = await this.conn;
1651
+ if (existingSocket.readyState === WebSocket.OPEN) {
1652
+ this.debug(`${methodName}: connection is healthy, skipping`);
1653
+ return Promise.resolve();
1654
+ }
1655
+ } catch {
1656
+ }
1657
+ }
1658
+ this.debug(
1659
+ `${methodName}: ${force ? "forcing immediate reconnection" : "reconnecting"}`
1660
+ );
1661
+ this.wsIntentionalDisconnect = false;
1662
+ if (this.wsReconnectTimer !== null) {
1663
+ this.debug(`${methodName}: cancelling pending reconnection timer`);
1664
+ clearTimeout(this.wsReconnectTimer);
1665
+ this.wsReconnectTimer = null;
1666
+ }
1667
+ this.wsReconnectAttempts = 0;
1668
+ if (this.conn !== null) {
1669
+ this.debug(`${methodName}: closing existing connection`);
1670
+ try {
1671
+ const existingSocket = await this.conn;
1672
+ if (this.currentWebSocket === existingSocket) {
1673
+ this.currentWebSocket = null;
1674
+ }
1675
+ if (existingSocket.readyState === WebSocket.OPEN || existingSocket.readyState === WebSocket.CONNECTING) {
1676
+ existingSocket.close();
1677
+ }
1678
+ } catch (error) {
1679
+ this.debug(`${methodName}: error closing existing connection:`, error);
1680
+ }
1681
+ this.conn = null;
1682
+ this.isConnecting = false;
1683
+ }
1684
+ if (this.context.company !== void 0 || this.context.user !== void 0) {
1685
+ this.debug(`${methodName}: reconnecting with existing context`);
1686
+ try {
1687
+ this.isConnecting = true;
1688
+ this.conn = this.wsConnect();
1689
+ const socket = await this.conn;
1690
+ this.isConnecting = false;
1691
+ await this.wsSendMessage(socket, this.context, true);
1692
+ this.debug(`${methodName}: reconnection successful`);
1693
+ } catch (error) {
1694
+ this.isConnecting = false;
1695
+ this.debug(`${methodName}: reconnection failed:`, error);
1696
+ }
1697
+ } else {
1698
+ this.debug(`${methodName}: no context set, skipping reconnection`);
1699
+ }
1700
+ return Promise.resolve();
1701
+ };
1288
1702
  /**
1289
1703
  * If using websocket mode, close the connection when done.
1290
1704
  * In offline mode, this is a no-op.
@@ -1294,95 +1708,290 @@ var Schematic = class {
1294
1708
  this.debug("cleanup: skipped (offline mode)");
1295
1709
  return Promise.resolve();
1296
1710
  }
1711
+ this.wsIntentionalDisconnect = true;
1712
+ if (this.wsReconnectTimer !== null) {
1713
+ clearTimeout(this.wsReconnectTimer);
1714
+ this.wsReconnectTimer = null;
1715
+ }
1716
+ this.stopRetryTimer();
1297
1717
  if (this.conn) {
1298
1718
  try {
1299
1719
  const socket = await this.conn;
1720
+ if (this.currentWebSocket === socket) {
1721
+ this.debug(`Cleaning up current websocket tracking`);
1722
+ this.currentWebSocket = null;
1723
+ }
1300
1724
  socket.close();
1301
1725
  } catch (error) {
1302
- console.error("Error during cleanup:", error);
1726
+ console.warn("Error during cleanup:", error);
1303
1727
  } finally {
1304
1728
  this.conn = null;
1729
+ this.currentWebSocket = null;
1730
+ this.isConnecting = false;
1305
1731
  }
1306
1732
  }
1307
1733
  };
1308
- // Open a websocket connection
1309
- wsConnect = () => {
1734
+ /**
1735
+ * Calculate the delay for the next reconnection attempt using exponential backoff with jitter.
1736
+ * This helps prevent dogpiling when the server recovers from an outage.
1737
+ */
1738
+ calculateReconnectDelay = () => {
1739
+ const exponentialDelay = this.webSocketInitialRetryDelay * Math.pow(2, this.wsReconnectAttempts);
1740
+ const cappedDelay = Math.min(exponentialDelay, this.webSocketMaxRetryDelay);
1741
+ const jitter = Math.random() * cappedDelay * 0.5;
1742
+ const totalDelay = cappedDelay + jitter;
1743
+ this.debug(
1744
+ `Reconnect delay calculated: ${totalDelay.toFixed(0)}ms (attempt ${this.wsReconnectAttempts + 1}/${this.webSocketMaxReconnectAttempts})`
1745
+ );
1746
+ return totalDelay;
1747
+ };
1748
+ /**
1749
+ * Handle browser going offline
1750
+ */
1751
+ handleNetworkOffline = async () => {
1752
+ if (this.conn !== null) {
1753
+ try {
1754
+ const socket = await this.conn;
1755
+ if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
1756
+ socket.close();
1757
+ }
1758
+ } catch (error) {
1759
+ this.debug("Error closing connection on offline:", error);
1760
+ }
1761
+ this.conn = null;
1762
+ }
1763
+ if (this.wsReconnectTimer !== null) {
1764
+ clearTimeout(this.wsReconnectTimer);
1765
+ this.wsReconnectTimer = null;
1766
+ }
1767
+ };
1768
+ /**
1769
+ * Handle browser coming back online
1770
+ */
1771
+ handleNetworkOnline = () => {
1772
+ this.debug(
1773
+ "Network online, attempting reconnection and flushing queued events"
1774
+ );
1775
+ this.wsReconnectAttempts = 0;
1776
+ if (this.wsReconnectTimer !== null) {
1777
+ clearTimeout(this.wsReconnectTimer);
1778
+ this.wsReconnectTimer = null;
1779
+ }
1780
+ this.flushEventQueue().catch((error) => {
1781
+ this.debug("Error flushing event queue on network online:", error);
1782
+ });
1783
+ this.attemptReconnect();
1784
+ };
1785
+ /**
1786
+ * Attempt to reconnect the WebSocket connection with exponential backoff.
1787
+ * Called automatically when the connection closes unexpectedly.
1788
+ */
1789
+ attemptReconnect = () => {
1790
+ if (this.wsReconnectAttempts >= this.webSocketMaxReconnectAttempts) {
1791
+ this.debug(
1792
+ `Maximum reconnection attempts (${this.webSocketMaxReconnectAttempts}) reached, giving up`
1793
+ );
1794
+ return;
1795
+ }
1796
+ if (this.wsReconnectTimer !== null) {
1797
+ this.debug(
1798
+ `Reconnection attempt already scheduled, ignoring duplicate request`
1799
+ );
1800
+ return;
1801
+ }
1802
+ const delay = this.calculateReconnectDelay();
1803
+ this.debug(
1804
+ `Scheduling reconnection attempt ${this.wsReconnectAttempts + 1}/${this.webSocketMaxReconnectAttempts} in ${delay.toFixed(0)}ms`
1805
+ );
1806
+ this.wsReconnectTimer = setTimeout(async () => {
1807
+ this.wsReconnectTimer = null;
1808
+ this.wsReconnectAttempts++;
1809
+ this.debug(
1810
+ `Attempting to reconnect (attempt ${this.wsReconnectAttempts}/${this.webSocketMaxReconnectAttempts})`
1811
+ );
1812
+ try {
1813
+ if (this.conn !== null) {
1814
+ this.debug(`Cleaning up existing connection before reconnection`);
1815
+ try {
1816
+ const existingSocket = await this.conn;
1817
+ if (this.currentWebSocket === existingSocket) {
1818
+ this.debug(`Existing websocket is current, will be replaced`);
1819
+ this.currentWebSocket = null;
1820
+ }
1821
+ if (existingSocket.readyState === WebSocket.OPEN || existingSocket.readyState === WebSocket.CONNECTING) {
1822
+ existingSocket.close();
1823
+ }
1824
+ } catch (error) {
1825
+ this.debug(`Error cleaning up existing connection:`, error);
1826
+ }
1827
+ this.conn = null;
1828
+ this.currentWebSocket = null;
1829
+ this.isConnecting = false;
1830
+ }
1831
+ this.isConnecting = true;
1832
+ try {
1833
+ this.conn = this.wsConnect();
1834
+ const socket = await this.conn;
1835
+ this.isConnecting = false;
1836
+ this.debug(`Reconnection context check:`, {
1837
+ hasCompany: this.context.company !== void 0,
1838
+ hasUser: this.context.user !== void 0,
1839
+ context: this.context
1840
+ });
1841
+ if (this.context.company !== void 0 || this.context.user !== void 0) {
1842
+ this.debug(`Reconnected, force re-sending context`);
1843
+ await this.wsSendMessage(socket, this.context, true);
1844
+ } else {
1845
+ this.debug(
1846
+ `No context to re-send after reconnection - websocket ready for new context`
1847
+ );
1848
+ this.debug(
1849
+ `Setting up tracking for reconnected websocket (no context to send)`
1850
+ );
1851
+ this.currentWebSocket = socket;
1852
+ }
1853
+ this.flushEventQueue().catch((error) => {
1854
+ this.debug(
1855
+ "Error flushing event queue after websocket reconnection:",
1856
+ error
1857
+ );
1858
+ });
1859
+ this.debug(`Reconnection successful`);
1860
+ } catch (error) {
1861
+ this.isConnecting = false;
1862
+ throw error;
1863
+ }
1864
+ } catch (error) {
1865
+ this.debug(`Reconnection attempt failed:`, error);
1866
+ }
1867
+ }, delay);
1868
+ };
1869
+ // Open a websocket connection with retry logic for timeouts
1870
+ wsConnect = async () => {
1310
1871
  if (this.isOffline()) {
1311
1872
  this.debug("wsConnect: skipped (offline mode)");
1312
- return Promise.reject(
1313
- new Error("WebSocket connection skipped in offline mode")
1314
- );
1873
+ throw new Error("WebSocket connection skipped in offline mode");
1315
1874
  }
1875
+ let lastError = null;
1876
+ const maxAttempts = this.webSocketMaxConnectionAttempts;
1877
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
1878
+ try {
1879
+ const socket = await this.wsConnectOnce();
1880
+ this.wsReconnectAttempts = 0;
1881
+ return socket;
1882
+ } catch (error) {
1883
+ lastError = error instanceof Error ? error : new Error(String(error));
1884
+ const isTimeout = lastError.message === "WebSocket connection timeout";
1885
+ if (!isTimeout) {
1886
+ this.debug(
1887
+ `WebSocket connection failed with non-timeout error, not retrying:`,
1888
+ lastError.message
1889
+ );
1890
+ throw lastError;
1891
+ }
1892
+ if (attempt < maxAttempts - 1) {
1893
+ const baseDelay = this.webSocketInitialRetryDelay * Math.pow(2, attempt);
1894
+ const cappedDelay = Math.min(baseDelay, this.webSocketMaxRetryDelay);
1895
+ const jitter = cappedDelay * 0.2 * Math.random();
1896
+ const delay = cappedDelay + jitter;
1897
+ this.debug(
1898
+ `WebSocket connection timeout (attempt ${attempt + 1}/${maxAttempts}), retrying in ${delay.toFixed(0)}ms`
1899
+ );
1900
+ await new Promise((resolve) => setTimeout(resolve, delay));
1901
+ } else {
1902
+ this.debug(
1903
+ `WebSocket connection timeout (attempt ${attempt + 1}/${maxAttempts}), no more retries`
1904
+ );
1905
+ }
1906
+ }
1907
+ }
1908
+ throw lastError ?? new Error("WebSocket connection failed");
1909
+ };
1910
+ // Single attempt to open a websocket connection (no retry logic)
1911
+ wsConnectOnce = () => {
1316
1912
  return new Promise((resolve, reject) => {
1317
1913
  const wsUrl = `${this.webSocketUrl}/flags/bootstrap?apiKey=${this.apiKey}`;
1318
1914
  this.debug(`connecting to WebSocket:`, wsUrl);
1319
1915
  const webSocket = new WebSocket(wsUrl);
1916
+ const connectionId = Math.random().toString(36).substring(7);
1917
+ this.debug(`Creating WebSocket connection ${connectionId} to ${wsUrl}`);
1918
+ let timeoutId = null;
1919
+ let isResolved = false;
1920
+ timeoutId = setTimeout(() => {
1921
+ if (!isResolved) {
1922
+ isResolved = true;
1923
+ this.debug(
1924
+ `WebSocket connection timeout after ${this.webSocketConnectionTimeout}ms`
1925
+ );
1926
+ webSocket.close();
1927
+ reject(new Error("WebSocket connection timeout"));
1928
+ }
1929
+ }, this.webSocketConnectionTimeout);
1320
1930
  webSocket.onopen = () => {
1321
- this.debug(`WebSocket connection opened`);
1931
+ if (isResolved) return;
1932
+ isResolved = true;
1933
+ if (timeoutId !== null) {
1934
+ clearTimeout(timeoutId);
1935
+ }
1936
+ this.wsIntentionalDisconnect = false;
1937
+ this.debug(`WebSocket connection ${connectionId} opened successfully`);
1322
1938
  resolve(webSocket);
1323
1939
  };
1324
1940
  webSocket.onerror = (error) => {
1325
- this.debug(`WebSocket connection error:`, error);
1941
+ if (isResolved) return;
1942
+ isResolved = true;
1943
+ if (timeoutId !== null) {
1944
+ clearTimeout(timeoutId);
1945
+ }
1946
+ this.debug(`WebSocket connection ${connectionId} error:`, error);
1326
1947
  reject(error);
1327
1948
  };
1328
1949
  webSocket.onclose = () => {
1329
- this.debug(`WebSocket connection closed`);
1950
+ if (timeoutId !== null) {
1951
+ clearTimeout(timeoutId);
1952
+ }
1953
+ this.debug(`WebSocket connection ${connectionId} closed`);
1330
1954
  this.conn = null;
1955
+ if (this.currentWebSocket === webSocket) {
1956
+ this.currentWebSocket = null;
1957
+ this.isConnecting = false;
1958
+ }
1959
+ if (!isResolved && !this.wsIntentionalDisconnect && this.webSocketReconnect) {
1960
+ this.attemptReconnect();
1961
+ }
1331
1962
  };
1332
1963
  });
1333
1964
  };
1334
1965
  // Send a message on the websocket indicating interest in a particular evaluation context
1335
1966
  // and wait for the initial set of flag values to be returned
1336
- wsSendMessage = (socket, context) => {
1967
+ wsSendMessage = (socket, context, forceContextSend = false) => {
1337
1968
  if (this.isOffline()) {
1338
1969
  this.debug("wsSendMessage: skipped (offline mode)");
1339
1970
  this.setIsPending(false);
1340
1971
  return Promise.resolve();
1341
1972
  }
1342
1973
  return new Promise((resolve, reject) => {
1343
- if (contextString(context) == contextString(this.context)) {
1974
+ if (!forceContextSend && contextString(context) == contextString(this.context)) {
1344
1975
  this.debug(`WebSocket context unchanged, skipping update`);
1345
1976
  return resolve(this.setIsPending(false));
1346
1977
  }
1347
- this.debug(`WebSocket context updated:`, context);
1978
+ this.debug(
1979
+ forceContextSend ? `WebSocket force sending context (reconnection):` : `WebSocket context updated:`,
1980
+ context
1981
+ );
1348
1982
  this.context = context;
1349
1983
  const sendMessage = () => {
1350
1984
  let resolved = false;
1985
+ const persistentMessageHandler = this.createPersistentMessageHandler(context);
1351
1986
  const messageHandler = (event) => {
1352
- const message = JSON.parse(event.data);
1353
- this.debug(`WebSocket message received:`, message);
1354
- if (!(contextString(context) in this.checks)) {
1355
- this.checks[contextString(context)] = {};
1356
- }
1357
- (message.flags ?? []).forEach((flag) => {
1358
- const flagCheck = CheckFlagReturnFromJSON(flag);
1359
- const contextStr = contextString(context);
1360
- if (this.checks[contextStr] === void 0) {
1361
- this.checks[contextStr] = {};
1362
- }
1363
- this.checks[contextStr][flagCheck.flag] = flagCheck;
1364
- this.debug(`WebSocket flag update:`, {
1365
- flag: flagCheck.flag,
1366
- value: flagCheck.value,
1367
- flagCheck
1368
- });
1369
- if (typeof flagCheck.featureUsageEvent === "string") {
1370
- this.updateFeatureUsageEventMap(flagCheck);
1371
- }
1372
- if (this.flagCheckListeners[flag.flag]?.size > 0 || this.flagValueListeners[flag.flag]?.size > 0) {
1373
- this.submitFlagCheckEvent(flagCheck.flag, flagCheck, context);
1374
- }
1375
- this.notifyFlagCheckListeners(flag.flag, flagCheck);
1376
- this.notifyFlagValueListeners(flag.flag, flagCheck.value);
1377
- });
1378
- this.flushContextDependentEventQueue();
1379
- this.setIsPending(false);
1987
+ persistentMessageHandler(event);
1380
1988
  if (!resolved) {
1381
1989
  resolved = true;
1382
1990
  resolve();
1383
1991
  }
1384
1992
  };
1385
1993
  socket.addEventListener("message", messageHandler);
1994
+ this.currentWebSocket = socket;
1386
1995
  const clientVersion = this.additionalHeaders["X-Schematic-Client-Version"] ?? `schematic-js@${version}`;
1387
1996
  const messagePayload = {
1388
1997
  apiKey: this.apiKey,
@@ -1427,12 +2036,31 @@ var Schematic = class {
1427
2036
  getFlagCheck = (flagKey) => {
1428
2037
  const contextStr = contextString(this.context);
1429
2038
  const checks = this.checks[contextStr] ?? {};
1430
- return checks[flagKey];
2039
+ const check = checks[flagKey];
2040
+ if (check !== void 0) {
2041
+ return check;
2042
+ }
2043
+ if (flagKey in this.flagCheckDefaults || flagKey in this.flagValueDefaults) {
2044
+ return this.resolveFallbackCheckFlagReturn(
2045
+ flagKey,
2046
+ void 0,
2047
+ "Default value used"
2048
+ );
2049
+ }
2050
+ return void 0;
1431
2051
  };
1432
2052
  // flagValues state
1433
2053
  getFlagValue = (flagKey) => {
1434
- const check = this.getFlagCheck(flagKey);
1435
- return check?.value;
2054
+ const contextStr = contextString(this.context);
2055
+ const checks = this.checks[contextStr] ?? {};
2056
+ const check = checks[flagKey];
2057
+ if (check?.value !== void 0) {
2058
+ return check.value;
2059
+ }
2060
+ if (flagKey in this.flagCheckDefaults || flagKey in this.flagValueDefaults) {
2061
+ return this.resolveFallbackValue(flagKey);
2062
+ }
2063
+ return void 0;
1436
2064
  };
1437
2065
  /** Register an event listener that will be notified with the boolean value for a given flag when this value changes */
1438
2066
  addFlagValueListener = (flagKey, listener) => {
@@ -1490,7 +2118,17 @@ var Schematic = class {
1490
2118
  { value }
1491
2119
  );
1492
2120
  }
1493
- listeners.forEach((listener) => notifyFlagValueListener(listener, value));
2121
+ listeners.forEach((listener, index) => {
2122
+ this.debug(`Calling listener ${index} for flag ${flagKey}`, {
2123
+ flagKey,
2124
+ value
2125
+ });
2126
+ notifyFlagValueListener(listener, value);
2127
+ this.debug(`Listener ${index} for flag ${flagKey} completed`, {
2128
+ flagKey,
2129
+ value
2130
+ });
2131
+ });
1494
2132
  };
1495
2133
  };
1496
2134
  var notifyPendingListener = (listener, value) => {
@@ -1519,7 +2157,7 @@ var notifyFlagValueListener = (listener, value) => {
1519
2157
  import { inject, onScopeDispose } from "vue";
1520
2158
 
1521
2159
  // src/version.ts
1522
- var version2 = "0.1.0";
2160
+ var version2 = "1.2.16";
1523
2161
 
1524
2162
  // src/context/schematic.ts
1525
2163
  var SchematicSymbol = Symbol("schematic");