@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.
@@ -790,7 +790,7 @@ function contextString(context) {
790
790
  }, {});
791
791
  return JSON.stringify(sortedContext);
792
792
  }
793
- var version = "1.2.6";
793
+ var version = "1.2.16";
794
794
  var anonymousIdKey = "schematicId";
795
795
  var Schematic = class {
796
796
  additionalHeaders = {};
@@ -812,6 +812,30 @@ var Schematic = class {
812
812
  checks = {};
813
813
  featureUsageEventMap = {};
814
814
  webSocketUrl = "wss://api.schematichq.com";
815
+ webSocketConnectionTimeout = 1e4;
816
+ webSocketReconnect = true;
817
+ webSocketMaxReconnectAttempts = 7;
818
+ // Max attempts after connection disrupted
819
+ webSocketMaxConnectionAttempts = 3;
820
+ // Max attempts for initial connection
821
+ webSocketInitialRetryDelay = 1e3;
822
+ webSocketMaxRetryDelay = 3e4;
823
+ wsReconnectAttempts = 0;
824
+ wsReconnectTimer = null;
825
+ wsIntentionalDisconnect = false;
826
+ currentWebSocket = null;
827
+ isConnecting = false;
828
+ maxEventQueueSize = 100;
829
+ // Prevent memory issues with very long network outages
830
+ maxEventRetries = 5;
831
+ // Maximum retry attempts for failed events
832
+ eventRetryInitialDelay = 1e3;
833
+ // Initial retry delay in ms
834
+ eventRetryMaxDelay = 3e4;
835
+ // Maximum retry delay in ms
836
+ retryTimer = null;
837
+ flagValueDefaults = {};
838
+ flagCheckDefaults = {};
815
839
  constructor(apiKey, options) {
816
840
  this.apiKey = apiKey;
817
841
  this.eventQueue = [];
@@ -843,8 +867,13 @@ var Schematic = class {
843
867
  };
844
868
  if (options?.storage) {
845
869
  this.storage = options.storage;
846
- } else if (typeof localStorage !== "undefined") {
847
- this.storage = localStorage;
870
+ } else {
871
+ try {
872
+ if (typeof localStorage !== "undefined") {
873
+ this.storage = localStorage;
874
+ }
875
+ } catch {
876
+ }
848
877
  }
849
878
  if (options?.apiUrl !== void 0) {
850
879
  this.apiUrl = options.apiUrl;
@@ -855,11 +884,54 @@ var Schematic = class {
855
884
  if (options?.webSocketUrl !== void 0) {
856
885
  this.webSocketUrl = options.webSocketUrl;
857
886
  }
887
+ if (options?.webSocketConnectionTimeout !== void 0) {
888
+ this.webSocketConnectionTimeout = options.webSocketConnectionTimeout;
889
+ }
890
+ if (options?.webSocketReconnect !== void 0) {
891
+ this.webSocketReconnect = options.webSocketReconnect;
892
+ }
893
+ if (options?.webSocketMaxReconnectAttempts !== void 0) {
894
+ this.webSocketMaxReconnectAttempts = options.webSocketMaxReconnectAttempts;
895
+ }
896
+ if (options?.webSocketInitialRetryDelay !== void 0) {
897
+ this.webSocketInitialRetryDelay = options.webSocketInitialRetryDelay;
898
+ }
899
+ if (options?.webSocketMaxRetryDelay !== void 0) {
900
+ this.webSocketMaxRetryDelay = options.webSocketMaxRetryDelay;
901
+ }
902
+ if (options?.maxEventQueueSize !== void 0) {
903
+ this.maxEventQueueSize = options.maxEventQueueSize;
904
+ }
905
+ if (options?.maxEventRetries !== void 0) {
906
+ this.maxEventRetries = options.maxEventRetries;
907
+ }
908
+ if (options?.eventRetryInitialDelay !== void 0) {
909
+ this.eventRetryInitialDelay = options.eventRetryInitialDelay;
910
+ }
911
+ if (options?.eventRetryMaxDelay !== void 0) {
912
+ this.eventRetryMaxDelay = options.eventRetryMaxDelay;
913
+ }
914
+ if (options?.flagValueDefaults !== void 0) {
915
+ this.flagValueDefaults = options.flagValueDefaults;
916
+ }
917
+ if (options?.flagCheckDefaults !== void 0) {
918
+ this.flagCheckDefaults = options.flagCheckDefaults;
919
+ }
858
920
  if (typeof window !== "undefined" && window?.addEventListener) {
859
921
  window.addEventListener("beforeunload", () => {
860
922
  this.flushEventQueue();
861
923
  this.flushContextDependentEventQueue();
862
924
  });
925
+ if (this.useWebSocket) {
926
+ window.addEventListener("offline", () => {
927
+ this.debug("Browser went offline, closing WebSocket connection");
928
+ this.handleNetworkOffline();
929
+ });
930
+ window.addEventListener("online", () => {
931
+ this.debug("Browser came online, attempting to reconnect WebSocket");
932
+ this.handleNetworkOnline();
933
+ });
934
+ }
863
935
  }
864
936
  if (this.offlineEnabled) {
865
937
  this.debug(
@@ -869,6 +941,66 @@ var Schematic = class {
869
941
  this.debug("Initialized with debug mode enabled");
870
942
  }
871
943
  }
944
+ /**
945
+ * Resolve fallback value according to priority order:
946
+ * 1. Callsite fallback value (if provided)
947
+ * 2. Boolean value from flagCheckDefaults initialization option
948
+ * 3. Boolean value from flagValueDefaults initialization option
949
+ * 4. Default to false
950
+ */
951
+ resolveFallbackValue(key, callsiteFallback) {
952
+ if (callsiteFallback !== void 0) {
953
+ return callsiteFallback;
954
+ }
955
+ if (key in this.flagCheckDefaults) {
956
+ return this.flagCheckDefaults[key].value;
957
+ }
958
+ if (key in this.flagValueDefaults) {
959
+ return this.flagValueDefaults[key];
960
+ }
961
+ return false;
962
+ }
963
+ /**
964
+ * Resolve complete CheckFlagReturn object according to priority order:
965
+ * 1. Use callsite fallback for boolean value, construct CheckFlagReturn
966
+ * 2. Use flagCheckDefaults if available for this flag
967
+ * 3. Use flagValueDefaults if available for this flag, construct CheckFlagReturn
968
+ * 4. Default CheckFlagReturn with value: false
969
+ */
970
+ resolveFallbackCheckFlagReturn(key, callsiteFallback, reason = "Fallback value used", error) {
971
+ if (callsiteFallback !== void 0) {
972
+ return {
973
+ flag: key,
974
+ value: callsiteFallback,
975
+ reason,
976
+ error
977
+ };
978
+ }
979
+ if (key in this.flagCheckDefaults) {
980
+ const defaultReturn = this.flagCheckDefaults[key];
981
+ return {
982
+ ...defaultReturn,
983
+ flag: key,
984
+ // Ensure flag matches the requested key
985
+ reason: error !== void 0 ? reason : defaultReturn.reason,
986
+ error
987
+ };
988
+ }
989
+ if (key in this.flagValueDefaults) {
990
+ return {
991
+ flag: key,
992
+ value: this.flagValueDefaults[key],
993
+ reason,
994
+ error
995
+ };
996
+ }
997
+ return {
998
+ flag: key,
999
+ value: false,
1000
+ reason,
1001
+ error
1002
+ };
1003
+ }
872
1004
  /**
873
1005
  * Get value for a single flag.
874
1006
  * In WebSocket mode, returns cached values if connection is active, otherwise establishes
@@ -877,16 +1009,21 @@ var Schematic = class {
877
1009
  * In REST mode, makes an API call for each check.
878
1010
  */
879
1011
  async checkFlag(options) {
880
- const { fallback = false, key } = options;
1012
+ const { fallback, key } = options;
881
1013
  const context = options.context || this.context;
882
1014
  const contextStr = contextString(context);
883
1015
  this.debug(`checkFlag: ${key}`, { context, fallback });
884
1016
  if (this.isOffline()) {
1017
+ const resolvedFallbackResult = this.resolveFallbackCheckFlagReturn(
1018
+ key,
1019
+ fallback,
1020
+ "Offline mode - using initialization defaults"
1021
+ );
885
1022
  this.debug(`checkFlag offline result: ${key}`, {
886
- value: fallback,
1023
+ value: resolvedFallbackResult.value,
887
1024
  offlineMode: true
888
1025
  });
889
- return fallback;
1026
+ return resolvedFallbackResult.value;
890
1027
  }
891
1028
  if (!this.useWebSocket) {
892
1029
  const requestUrl = `${this.apiUrl}/flags/${key}/check`;
@@ -913,15 +1050,15 @@ var Schematic = class {
913
1050
  this.submitFlagCheckEvent(key, result, context);
914
1051
  return result.value;
915
1052
  }).catch((error) => {
916
- console.error("There was a problem with the fetch operation:", error);
917
- const errorResult = {
918
- flag: key,
919
- value: fallback,
920
- reason: "API request failed",
921
- error: error instanceof Error ? error.message : String(error)
922
- };
1053
+ console.warn("There was a problem with the fetch operation:", error);
1054
+ const errorResult = this.resolveFallbackCheckFlagReturn(
1055
+ key,
1056
+ fallback,
1057
+ "API request failed",
1058
+ error instanceof Error ? error.message : String(error)
1059
+ );
923
1060
  this.submitFlagCheckEvent(key, errorResult, context);
924
- return fallback;
1061
+ return errorResult.value;
925
1062
  });
926
1063
  }
927
1064
  try {
@@ -931,12 +1068,12 @@ var Schematic = class {
931
1068
  return existingVals[key].value;
932
1069
  }
933
1070
  if (this.isOffline()) {
934
- return fallback;
1071
+ return this.resolveFallbackValue(key, fallback);
935
1072
  }
936
1073
  try {
937
1074
  await this.setContext(context);
938
1075
  } catch (error) {
939
- console.error(
1076
+ console.warn(
940
1077
  "WebSocket connection failed, falling back to REST:",
941
1078
  error
942
1079
  );
@@ -944,10 +1081,10 @@ var Schematic = class {
944
1081
  }
945
1082
  const contextVals = this.checks[contextStr] ?? {};
946
1083
  const flagCheck = contextVals[key];
947
- const result = flagCheck?.value ?? fallback;
1084
+ const result = flagCheck?.value ?? this.resolveFallbackValue(key, fallback);
948
1085
  this.debug(
949
1086
  `checkFlag WebSocket result: ${key}`,
950
- typeof flagCheck !== "undefined" ? flagCheck : { value: fallback, fallbackUsed: true }
1087
+ typeof flagCheck !== "undefined" ? flagCheck : { value: result, fallbackUsed: true }
951
1088
  );
952
1089
  if (typeof flagCheck !== "undefined") {
953
1090
  this.submitFlagCheckEvent(key, flagCheck, context);
@@ -955,14 +1092,14 @@ var Schematic = class {
955
1092
  return result;
956
1093
  } catch (error) {
957
1094
  console.error("Unexpected error in checkFlag:", error);
958
- const errorResult = {
959
- flag: key,
960
- value: fallback,
961
- reason: "Unexpected error in flag check",
962
- error: error instanceof Error ? error.message : String(error)
963
- };
1095
+ const errorResult = this.resolveFallbackCheckFlagReturn(
1096
+ key,
1097
+ fallback,
1098
+ "Unexpected error in flag check",
1099
+ error instanceof Error ? error.message : String(error)
1100
+ );
964
1101
  this.submitFlagCheckEvent(key, errorResult, context);
965
- return fallback;
1102
+ return errorResult.value;
966
1103
  }
967
1104
  }
968
1105
  /**
@@ -974,6 +1111,49 @@ var Schematic = class {
974
1111
  console.log(`[Schematic] ${message}`, ...args);
975
1112
  }
976
1113
  }
1114
+ /**
1115
+ * Create a persistent message handler for websocket flag updates
1116
+ */
1117
+ createPersistentMessageHandler(context) {
1118
+ return (event) => {
1119
+ const message = JSON.parse(event.data);
1120
+ this.debug(`WebSocket persistent message received:`, message);
1121
+ if (!(contextString(context) in this.checks)) {
1122
+ this.checks[contextString(context)] = {};
1123
+ }
1124
+ (message.flags ?? []).forEach((flag) => {
1125
+ const flagCheck = CheckFlagReturnFromJSON(flag);
1126
+ const contextStr = contextString(context);
1127
+ if (this.checks[contextStr] === void 0) {
1128
+ this.checks[contextStr] = {};
1129
+ }
1130
+ this.checks[contextStr][flagCheck.flag] = flagCheck;
1131
+ this.debug(`WebSocket flag update:`, {
1132
+ flag: flagCheck.flag,
1133
+ value: flagCheck.value,
1134
+ flagCheck
1135
+ });
1136
+ if (typeof flagCheck.featureUsageEvent === "string") {
1137
+ this.updateFeatureUsageEventMap(flagCheck);
1138
+ }
1139
+ if ((this.flagCheckListeners[flag.flag]?.size ?? 0) > 0 || (this.flagValueListeners[flag.flag]?.size ?? 0) > 0) {
1140
+ this.submitFlagCheckEvent(flagCheck.flag, flagCheck, context);
1141
+ }
1142
+ this.debug(`About to notify listeners for flag ${flag.flag}`, {
1143
+ flag: flag.flag,
1144
+ value: flagCheck.value
1145
+ });
1146
+ this.notifyFlagCheckListeners(flag.flag, flagCheck);
1147
+ this.notifyFlagValueListeners(flag.flag, flagCheck.value);
1148
+ this.debug(`Finished notifying listeners for flag ${flag.flag}`, {
1149
+ flag: flag.flag,
1150
+ value: flagCheck.value
1151
+ });
1152
+ });
1153
+ this.flushContextDependentEventQueue();
1154
+ this.setIsPending(false);
1155
+ };
1156
+ }
977
1157
  /**
978
1158
  * Helper function to check if client is in offline mode
979
1159
  */
@@ -1005,11 +1185,12 @@ var Schematic = class {
1005
1185
  */
1006
1186
  async fallbackToRest(key, context, fallback) {
1007
1187
  if (this.isOffline()) {
1188
+ const resolvedFallback = this.resolveFallbackValue(key, fallback);
1008
1189
  this.debug(`fallbackToRest offline result: ${key}`, {
1009
- value: fallback,
1190
+ value: resolvedFallback,
1010
1191
  offlineMode: true
1011
1192
  });
1012
- return fallback;
1193
+ return resolvedFallback;
1013
1194
  }
1014
1195
  try {
1015
1196
  const requestUrl = `${this.apiUrl}/flags/${key}/check`;
@@ -1035,15 +1216,15 @@ var Schematic = class {
1035
1216
  this.submitFlagCheckEvent(key, result, context);
1036
1217
  return result.value;
1037
1218
  } catch (error) {
1038
- console.error("REST API call failed, using fallback value:", error);
1039
- const errorResult = {
1040
- flag: key,
1041
- value: fallback,
1042
- reason: "API request failed (fallback)",
1043
- error: error instanceof Error ? error.message : String(error)
1044
- };
1219
+ console.warn("REST API call failed, using fallback value:", error);
1220
+ const errorResult = this.resolveFallbackCheckFlagReturn(
1221
+ key,
1222
+ fallback,
1223
+ "API request failed (fallback)",
1224
+ error instanceof Error ? error.message : String(error)
1225
+ );
1045
1226
  this.submitFlagCheckEvent(key, errorResult, context);
1046
- return fallback;
1227
+ return errorResult.value;
1047
1228
  }
1048
1229
  }
1049
1230
  /**
@@ -1084,7 +1265,7 @@ var Schematic = class {
1084
1265
  {}
1085
1266
  );
1086
1267
  }).catch((error) => {
1087
- console.error("There was a problem with the fetch operation:", error);
1268
+ console.warn("There was a problem with the fetch operation:", error);
1088
1269
  return {};
1089
1270
  });
1090
1271
  };
@@ -1095,14 +1276,12 @@ var Schematic = class {
1095
1276
  */
1096
1277
  identify = (body) => {
1097
1278
  this.debug(`identify:`, body);
1098
- try {
1099
- this.setContext({
1100
- company: body.company?.keys,
1101
- user: body.keys
1102
- });
1103
- } catch (error) {
1104
- console.error("Error setting context:", error);
1105
- }
1279
+ this.setContext({
1280
+ company: body.company?.keys,
1281
+ user: body.keys
1282
+ }).catch((error) => {
1283
+ console.warn("Error setting context:", error);
1284
+ });
1106
1285
  return this.handleEvent("identify", body);
1107
1286
  };
1108
1287
  /**
@@ -1124,12 +1303,42 @@ var Schematic = class {
1124
1303
  try {
1125
1304
  this.setIsPending(true);
1126
1305
  if (!this.conn) {
1127
- this.conn = this.wsConnect();
1306
+ if (this.isConnecting) {
1307
+ this.debug(
1308
+ `Connection already in progress, waiting for it to complete`
1309
+ );
1310
+ while (this.isConnecting && this.conn === null) {
1311
+ await new Promise((resolve) => setTimeout(resolve, 10));
1312
+ }
1313
+ if (this.conn !== null) {
1314
+ const socket2 = await this.conn;
1315
+ await this.wsSendMessage(socket2, context);
1316
+ return;
1317
+ }
1318
+ }
1319
+ if (this.wsReconnectTimer !== null) {
1320
+ this.debug(
1321
+ `Cancelling scheduled reconnection, connecting immediately`
1322
+ );
1323
+ clearTimeout(this.wsReconnectTimer);
1324
+ this.wsReconnectTimer = null;
1325
+ }
1326
+ this.isConnecting = true;
1327
+ try {
1328
+ this.conn = this.wsConnect();
1329
+ const socket2 = await this.conn;
1330
+ this.isConnecting = false;
1331
+ await this.wsSendMessage(socket2, context);
1332
+ return;
1333
+ } catch (error) {
1334
+ this.isConnecting = false;
1335
+ throw error;
1336
+ }
1128
1337
  }
1129
1338
  const socket = await this.conn;
1130
1339
  await this.wsSendMessage(socket, context);
1131
1340
  } catch (error) {
1132
- console.error("Failed to establish WebSocket connection:", error);
1341
+ console.warn("Failed to establish WebSocket connection:", error);
1133
1342
  throw error;
1134
1343
  }
1135
1344
  };
@@ -1253,11 +1462,57 @@ var Schematic = class {
1253
1462
  }
1254
1463
  }
1255
1464
  };
1256
- flushEventQueue = () => {
1257
- while (this.eventQueue.length > 0) {
1258
- const event = this.eventQueue.shift();
1259
- if (event) {
1260
- this.sendEvent(event);
1465
+ startRetryTimer = () => {
1466
+ if (this.retryTimer !== null) {
1467
+ return;
1468
+ }
1469
+ this.retryTimer = setInterval(() => {
1470
+ this.flushEventQueue().catch((error) => {
1471
+ this.debug("Error in retry timer flush:", error);
1472
+ });
1473
+ if (this.eventQueue.length === 0) {
1474
+ this.stopRetryTimer();
1475
+ }
1476
+ }, 5e3);
1477
+ this.debug("Started retry timer");
1478
+ };
1479
+ stopRetryTimer = () => {
1480
+ if (this.retryTimer !== null) {
1481
+ clearInterval(this.retryTimer);
1482
+ this.retryTimer = null;
1483
+ this.debug("Stopped retry timer");
1484
+ }
1485
+ };
1486
+ flushEventQueue = async () => {
1487
+ if (this.eventQueue.length === 0) {
1488
+ return;
1489
+ }
1490
+ const now = Date.now();
1491
+ const readyEvents = [];
1492
+ const notReadyEvents = [];
1493
+ for (const event of this.eventQueue) {
1494
+ if (event.next_retry_at === void 0 || event.next_retry_at <= now) {
1495
+ readyEvents.push(event);
1496
+ } else {
1497
+ notReadyEvents.push(event);
1498
+ }
1499
+ }
1500
+ if (readyEvents.length === 0) {
1501
+ this.debug(
1502
+ `No events ready for retry yet (${notReadyEvents.length} still in backoff)`
1503
+ );
1504
+ return;
1505
+ }
1506
+ this.debug(
1507
+ `Flushing event queue: ${readyEvents.length} ready, ${notReadyEvents.length} waiting`
1508
+ );
1509
+ this.eventQueue = notReadyEvents;
1510
+ for (const event of readyEvents) {
1511
+ try {
1512
+ await this.sendEvent(event);
1513
+ this.debug(`Queued event sent successfully:`, event.type);
1514
+ } catch (error) {
1515
+ this.debug(`Failed to send queued event:`, error);
1261
1516
  }
1262
1517
  }
1263
1518
  };
@@ -1305,12 +1560,47 @@ var Schematic = class {
1305
1560
  },
1306
1561
  body: payload
1307
1562
  });
1563
+ if (!response.ok) {
1564
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
1565
+ }
1308
1566
  this.debug(`event sent:`, {
1309
1567
  status: response.status,
1310
1568
  statusText: response.statusText
1311
1569
  });
1312
1570
  } catch (error) {
1313
- console.error("Error sending Schematic event: ", error);
1571
+ const retryCount = (event.retry_count ?? 0) + 1;
1572
+ if (retryCount <= this.maxEventRetries) {
1573
+ this.debug(
1574
+ `Event failed to send (attempt ${retryCount}/${this.maxEventRetries}), queueing for retry:`,
1575
+ error
1576
+ );
1577
+ const baseDelay = this.eventRetryInitialDelay * Math.pow(2, retryCount - 1);
1578
+ const jitterDelay = Math.min(baseDelay, this.eventRetryMaxDelay);
1579
+ const nextRetryAt = Date.now() + jitterDelay;
1580
+ const retryEvent = {
1581
+ ...event,
1582
+ retry_count: retryCount,
1583
+ next_retry_at: nextRetryAt
1584
+ };
1585
+ if (this.eventQueue.length < this.maxEventQueueSize) {
1586
+ this.eventQueue.push(retryEvent);
1587
+ this.debug(
1588
+ `Event queued for retry in ${jitterDelay}ms (${this.eventQueue.length}/${this.maxEventQueueSize})`
1589
+ );
1590
+ } else {
1591
+ this.debug(
1592
+ `Event queue full (${this.maxEventQueueSize}), dropping oldest event`
1593
+ );
1594
+ this.eventQueue.shift();
1595
+ this.eventQueue.push(retryEvent);
1596
+ }
1597
+ this.startRetryTimer();
1598
+ } else {
1599
+ this.debug(
1600
+ `Event failed permanently after ${this.maxEventRetries} attempts, dropping:`,
1601
+ error
1602
+ );
1603
+ }
1314
1604
  }
1315
1605
  return Promise.resolve();
1316
1606
  };
@@ -1321,6 +1611,130 @@ var Schematic = class {
1321
1611
  /**
1322
1612
  * Websocket management
1323
1613
  */
1614
+ /**
1615
+ * Force an immediate WebSocket reconnection.
1616
+ * This is useful when the application returns from a background state (e.g., mobile app
1617
+ * coming back to foreground) and wants to immediately re-establish the connection
1618
+ * rather than waiting for the exponential backoff timer.
1619
+ *
1620
+ * This method will:
1621
+ * - Cancel any pending reconnection timer
1622
+ * - Reset the reconnection attempt counter
1623
+ * - Close any existing connection
1624
+ * - Immediately attempt to reconnect
1625
+ * - Re-send the current context to get fresh flag values
1626
+ *
1627
+ * Use this when you need guaranteed fresh values (e.g., after an in-app purchase).
1628
+ *
1629
+ * @example
1630
+ * ```typescript
1631
+ * // React Native example: reconnect when app comes to foreground
1632
+ * useEffect(() => {
1633
+ * const subscription = AppState.addEventListener("change", (state) => {
1634
+ * if (state === "active") {
1635
+ * client.forceReconnect();
1636
+ * }
1637
+ * });
1638
+ * return () => subscription.remove();
1639
+ * }, [client]);
1640
+ * ```
1641
+ */
1642
+ forceReconnect = async () => {
1643
+ return this.reconnect({ force: true });
1644
+ };
1645
+ /**
1646
+ * Reconnect the WebSocket connection only if the current connection is unhealthy.
1647
+ * This is useful when the application returns from a background state and wants to
1648
+ * ensure a healthy connection exists, but doesn't need to force a reconnection if
1649
+ * the connection is still active.
1650
+ *
1651
+ * This method will:
1652
+ * - Check if an existing connection is healthy (readyState === OPEN)
1653
+ * - If healthy, return immediately without reconnecting
1654
+ * - If unhealthy, perform the same reconnection logic as forceReconnect()
1655
+ *
1656
+ * Use this when you want efficient reconnection that avoids unnecessary disconnects.
1657
+ *
1658
+ * @example
1659
+ * ```typescript
1660
+ * // React Native example: reconnect only if needed when app comes to foreground
1661
+ * useEffect(() => {
1662
+ * const subscription = AppState.addEventListener("change", (state) => {
1663
+ * if (state === "active") {
1664
+ * client.reconnectIfNeeded();
1665
+ * }
1666
+ * });
1667
+ * return () => subscription.remove();
1668
+ * }, [client]);
1669
+ * ```
1670
+ */
1671
+ reconnectIfNeeded = async () => {
1672
+ return this.reconnect({ force: false });
1673
+ };
1674
+ /**
1675
+ * Internal method to handle reconnection logic for both forceReconnect and reconnectIfNeeded.
1676
+ */
1677
+ reconnect = async (options) => {
1678
+ const { force } = options;
1679
+ const methodName = force ? "forceReconnect" : "reconnectIfNeeded";
1680
+ if (this.isOffline()) {
1681
+ this.debug(`${methodName}: skipped (offline mode)`);
1682
+ return Promise.resolve();
1683
+ }
1684
+ if (!force && this.conn !== null) {
1685
+ try {
1686
+ const existingSocket = await this.conn;
1687
+ if (existingSocket.readyState === WebSocket.OPEN) {
1688
+ this.debug(`${methodName}: connection is healthy, skipping`);
1689
+ return Promise.resolve();
1690
+ }
1691
+ } catch {
1692
+ }
1693
+ }
1694
+ this.debug(
1695
+ `${methodName}: ${force ? "forcing immediate reconnection" : "reconnecting"}`
1696
+ );
1697
+ this.wsIntentionalDisconnect = false;
1698
+ if (this.wsReconnectTimer !== null) {
1699
+ this.debug(`${methodName}: cancelling pending reconnection timer`);
1700
+ clearTimeout(this.wsReconnectTimer);
1701
+ this.wsReconnectTimer = null;
1702
+ }
1703
+ this.wsReconnectAttempts = 0;
1704
+ if (this.conn !== null) {
1705
+ this.debug(`${methodName}: closing existing connection`);
1706
+ try {
1707
+ const existingSocket = await this.conn;
1708
+ if (this.currentWebSocket === existingSocket) {
1709
+ this.currentWebSocket = null;
1710
+ }
1711
+ if (existingSocket.readyState === WebSocket.OPEN || existingSocket.readyState === WebSocket.CONNECTING) {
1712
+ existingSocket.close();
1713
+ }
1714
+ } catch (error) {
1715
+ this.debug(`${methodName}: error closing existing connection:`, error);
1716
+ }
1717
+ this.conn = null;
1718
+ this.isConnecting = false;
1719
+ }
1720
+ if (this.context.company !== void 0 || this.context.user !== void 0) {
1721
+ this.debug(`${methodName}: reconnecting with existing context`);
1722
+ try {
1723
+ this.isConnecting = true;
1724
+ this.conn = this.wsConnect();
1725
+ const socket = await this.conn;
1726
+ this.isConnecting = false;
1727
+ await this.wsSendMessage(socket, this.context, true);
1728
+ this.debug(`${methodName}: reconnection successful`);
1729
+ } catch (error) {
1730
+ this.isConnecting = false;
1731
+ this.debug(`${methodName}: reconnection failed:`, error);
1732
+ }
1733
+ } else {
1734
+ this.debug(`${methodName}: no context set, skipping reconnection`);
1735
+ }
1736
+ return Promise.resolve();
1737
+ };
1324
1738
  /**
1325
1739
  * If using websocket mode, close the connection when done.
1326
1740
  * In offline mode, this is a no-op.
@@ -1330,95 +1744,290 @@ var Schematic = class {
1330
1744
  this.debug("cleanup: skipped (offline mode)");
1331
1745
  return Promise.resolve();
1332
1746
  }
1747
+ this.wsIntentionalDisconnect = true;
1748
+ if (this.wsReconnectTimer !== null) {
1749
+ clearTimeout(this.wsReconnectTimer);
1750
+ this.wsReconnectTimer = null;
1751
+ }
1752
+ this.stopRetryTimer();
1333
1753
  if (this.conn) {
1334
1754
  try {
1335
1755
  const socket = await this.conn;
1756
+ if (this.currentWebSocket === socket) {
1757
+ this.debug(`Cleaning up current websocket tracking`);
1758
+ this.currentWebSocket = null;
1759
+ }
1336
1760
  socket.close();
1337
1761
  } catch (error) {
1338
- console.error("Error during cleanup:", error);
1762
+ console.warn("Error during cleanup:", error);
1339
1763
  } finally {
1340
1764
  this.conn = null;
1765
+ this.currentWebSocket = null;
1766
+ this.isConnecting = false;
1341
1767
  }
1342
1768
  }
1343
1769
  };
1344
- // Open a websocket connection
1345
- wsConnect = () => {
1770
+ /**
1771
+ * Calculate the delay for the next reconnection attempt using exponential backoff with jitter.
1772
+ * This helps prevent dogpiling when the server recovers from an outage.
1773
+ */
1774
+ calculateReconnectDelay = () => {
1775
+ const exponentialDelay = this.webSocketInitialRetryDelay * Math.pow(2, this.wsReconnectAttempts);
1776
+ const cappedDelay = Math.min(exponentialDelay, this.webSocketMaxRetryDelay);
1777
+ const jitter = Math.random() * cappedDelay * 0.5;
1778
+ const totalDelay = cappedDelay + jitter;
1779
+ this.debug(
1780
+ `Reconnect delay calculated: ${totalDelay.toFixed(0)}ms (attempt ${this.wsReconnectAttempts + 1}/${this.webSocketMaxReconnectAttempts})`
1781
+ );
1782
+ return totalDelay;
1783
+ };
1784
+ /**
1785
+ * Handle browser going offline
1786
+ */
1787
+ handleNetworkOffline = async () => {
1788
+ if (this.conn !== null) {
1789
+ try {
1790
+ const socket = await this.conn;
1791
+ if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
1792
+ socket.close();
1793
+ }
1794
+ } catch (error) {
1795
+ this.debug("Error closing connection on offline:", error);
1796
+ }
1797
+ this.conn = null;
1798
+ }
1799
+ if (this.wsReconnectTimer !== null) {
1800
+ clearTimeout(this.wsReconnectTimer);
1801
+ this.wsReconnectTimer = null;
1802
+ }
1803
+ };
1804
+ /**
1805
+ * Handle browser coming back online
1806
+ */
1807
+ handleNetworkOnline = () => {
1808
+ this.debug(
1809
+ "Network online, attempting reconnection and flushing queued events"
1810
+ );
1811
+ this.wsReconnectAttempts = 0;
1812
+ if (this.wsReconnectTimer !== null) {
1813
+ clearTimeout(this.wsReconnectTimer);
1814
+ this.wsReconnectTimer = null;
1815
+ }
1816
+ this.flushEventQueue().catch((error) => {
1817
+ this.debug("Error flushing event queue on network online:", error);
1818
+ });
1819
+ this.attemptReconnect();
1820
+ };
1821
+ /**
1822
+ * Attempt to reconnect the WebSocket connection with exponential backoff.
1823
+ * Called automatically when the connection closes unexpectedly.
1824
+ */
1825
+ attemptReconnect = () => {
1826
+ if (this.wsReconnectAttempts >= this.webSocketMaxReconnectAttempts) {
1827
+ this.debug(
1828
+ `Maximum reconnection attempts (${this.webSocketMaxReconnectAttempts}) reached, giving up`
1829
+ );
1830
+ return;
1831
+ }
1832
+ if (this.wsReconnectTimer !== null) {
1833
+ this.debug(
1834
+ `Reconnection attempt already scheduled, ignoring duplicate request`
1835
+ );
1836
+ return;
1837
+ }
1838
+ const delay = this.calculateReconnectDelay();
1839
+ this.debug(
1840
+ `Scheduling reconnection attempt ${this.wsReconnectAttempts + 1}/${this.webSocketMaxReconnectAttempts} in ${delay.toFixed(0)}ms`
1841
+ );
1842
+ this.wsReconnectTimer = setTimeout(async () => {
1843
+ this.wsReconnectTimer = null;
1844
+ this.wsReconnectAttempts++;
1845
+ this.debug(
1846
+ `Attempting to reconnect (attempt ${this.wsReconnectAttempts}/${this.webSocketMaxReconnectAttempts})`
1847
+ );
1848
+ try {
1849
+ if (this.conn !== null) {
1850
+ this.debug(`Cleaning up existing connection before reconnection`);
1851
+ try {
1852
+ const existingSocket = await this.conn;
1853
+ if (this.currentWebSocket === existingSocket) {
1854
+ this.debug(`Existing websocket is current, will be replaced`);
1855
+ this.currentWebSocket = null;
1856
+ }
1857
+ if (existingSocket.readyState === WebSocket.OPEN || existingSocket.readyState === WebSocket.CONNECTING) {
1858
+ existingSocket.close();
1859
+ }
1860
+ } catch (error) {
1861
+ this.debug(`Error cleaning up existing connection:`, error);
1862
+ }
1863
+ this.conn = null;
1864
+ this.currentWebSocket = null;
1865
+ this.isConnecting = false;
1866
+ }
1867
+ this.isConnecting = true;
1868
+ try {
1869
+ this.conn = this.wsConnect();
1870
+ const socket = await this.conn;
1871
+ this.isConnecting = false;
1872
+ this.debug(`Reconnection context check:`, {
1873
+ hasCompany: this.context.company !== void 0,
1874
+ hasUser: this.context.user !== void 0,
1875
+ context: this.context
1876
+ });
1877
+ if (this.context.company !== void 0 || this.context.user !== void 0) {
1878
+ this.debug(`Reconnected, force re-sending context`);
1879
+ await this.wsSendMessage(socket, this.context, true);
1880
+ } else {
1881
+ this.debug(
1882
+ `No context to re-send after reconnection - websocket ready for new context`
1883
+ );
1884
+ this.debug(
1885
+ `Setting up tracking for reconnected websocket (no context to send)`
1886
+ );
1887
+ this.currentWebSocket = socket;
1888
+ }
1889
+ this.flushEventQueue().catch((error) => {
1890
+ this.debug(
1891
+ "Error flushing event queue after websocket reconnection:",
1892
+ error
1893
+ );
1894
+ });
1895
+ this.debug(`Reconnection successful`);
1896
+ } catch (error) {
1897
+ this.isConnecting = false;
1898
+ throw error;
1899
+ }
1900
+ } catch (error) {
1901
+ this.debug(`Reconnection attempt failed:`, error);
1902
+ }
1903
+ }, delay);
1904
+ };
1905
+ // Open a websocket connection with retry logic for timeouts
1906
+ wsConnect = async () => {
1346
1907
  if (this.isOffline()) {
1347
1908
  this.debug("wsConnect: skipped (offline mode)");
1348
- return Promise.reject(
1349
- new Error("WebSocket connection skipped in offline mode")
1350
- );
1909
+ throw new Error("WebSocket connection skipped in offline mode");
1351
1910
  }
1911
+ let lastError = null;
1912
+ const maxAttempts = this.webSocketMaxConnectionAttempts;
1913
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
1914
+ try {
1915
+ const socket = await this.wsConnectOnce();
1916
+ this.wsReconnectAttempts = 0;
1917
+ return socket;
1918
+ } catch (error) {
1919
+ lastError = error instanceof Error ? error : new Error(String(error));
1920
+ const isTimeout = lastError.message === "WebSocket connection timeout";
1921
+ if (!isTimeout) {
1922
+ this.debug(
1923
+ `WebSocket connection failed with non-timeout error, not retrying:`,
1924
+ lastError.message
1925
+ );
1926
+ throw lastError;
1927
+ }
1928
+ if (attempt < maxAttempts - 1) {
1929
+ const baseDelay = this.webSocketInitialRetryDelay * Math.pow(2, attempt);
1930
+ const cappedDelay = Math.min(baseDelay, this.webSocketMaxRetryDelay);
1931
+ const jitter = cappedDelay * 0.2 * Math.random();
1932
+ const delay = cappedDelay + jitter;
1933
+ this.debug(
1934
+ `WebSocket connection timeout (attempt ${attempt + 1}/${maxAttempts}), retrying in ${delay.toFixed(0)}ms`
1935
+ );
1936
+ await new Promise((resolve) => setTimeout(resolve, delay));
1937
+ } else {
1938
+ this.debug(
1939
+ `WebSocket connection timeout (attempt ${attempt + 1}/${maxAttempts}), no more retries`
1940
+ );
1941
+ }
1942
+ }
1943
+ }
1944
+ throw lastError ?? new Error("WebSocket connection failed");
1945
+ };
1946
+ // Single attempt to open a websocket connection (no retry logic)
1947
+ wsConnectOnce = () => {
1352
1948
  return new Promise((resolve, reject) => {
1353
1949
  const wsUrl = `${this.webSocketUrl}/flags/bootstrap?apiKey=${this.apiKey}`;
1354
1950
  this.debug(`connecting to WebSocket:`, wsUrl);
1355
1951
  const webSocket = new WebSocket(wsUrl);
1952
+ const connectionId = Math.random().toString(36).substring(7);
1953
+ this.debug(`Creating WebSocket connection ${connectionId} to ${wsUrl}`);
1954
+ let timeoutId = null;
1955
+ let isResolved = false;
1956
+ timeoutId = setTimeout(() => {
1957
+ if (!isResolved) {
1958
+ isResolved = true;
1959
+ this.debug(
1960
+ `WebSocket connection timeout after ${this.webSocketConnectionTimeout}ms`
1961
+ );
1962
+ webSocket.close();
1963
+ reject(new Error("WebSocket connection timeout"));
1964
+ }
1965
+ }, this.webSocketConnectionTimeout);
1356
1966
  webSocket.onopen = () => {
1357
- this.debug(`WebSocket connection opened`);
1967
+ if (isResolved) return;
1968
+ isResolved = true;
1969
+ if (timeoutId !== null) {
1970
+ clearTimeout(timeoutId);
1971
+ }
1972
+ this.wsIntentionalDisconnect = false;
1973
+ this.debug(`WebSocket connection ${connectionId} opened successfully`);
1358
1974
  resolve(webSocket);
1359
1975
  };
1360
1976
  webSocket.onerror = (error) => {
1361
- this.debug(`WebSocket connection error:`, error);
1977
+ if (isResolved) return;
1978
+ isResolved = true;
1979
+ if (timeoutId !== null) {
1980
+ clearTimeout(timeoutId);
1981
+ }
1982
+ this.debug(`WebSocket connection ${connectionId} error:`, error);
1362
1983
  reject(error);
1363
1984
  };
1364
1985
  webSocket.onclose = () => {
1365
- this.debug(`WebSocket connection closed`);
1986
+ if (timeoutId !== null) {
1987
+ clearTimeout(timeoutId);
1988
+ }
1989
+ this.debug(`WebSocket connection ${connectionId} closed`);
1366
1990
  this.conn = null;
1991
+ if (this.currentWebSocket === webSocket) {
1992
+ this.currentWebSocket = null;
1993
+ this.isConnecting = false;
1994
+ }
1995
+ if (!isResolved && !this.wsIntentionalDisconnect && this.webSocketReconnect) {
1996
+ this.attemptReconnect();
1997
+ }
1367
1998
  };
1368
1999
  });
1369
2000
  };
1370
2001
  // Send a message on the websocket indicating interest in a particular evaluation context
1371
2002
  // and wait for the initial set of flag values to be returned
1372
- wsSendMessage = (socket, context) => {
2003
+ wsSendMessage = (socket, context, forceContextSend = false) => {
1373
2004
  if (this.isOffline()) {
1374
2005
  this.debug("wsSendMessage: skipped (offline mode)");
1375
2006
  this.setIsPending(false);
1376
2007
  return Promise.resolve();
1377
2008
  }
1378
2009
  return new Promise((resolve, reject) => {
1379
- if (contextString(context) == contextString(this.context)) {
2010
+ if (!forceContextSend && contextString(context) == contextString(this.context)) {
1380
2011
  this.debug(`WebSocket context unchanged, skipping update`);
1381
2012
  return resolve(this.setIsPending(false));
1382
2013
  }
1383
- this.debug(`WebSocket context updated:`, context);
2014
+ this.debug(
2015
+ forceContextSend ? `WebSocket force sending context (reconnection):` : `WebSocket context updated:`,
2016
+ context
2017
+ );
1384
2018
  this.context = context;
1385
2019
  const sendMessage = () => {
1386
2020
  let resolved = false;
2021
+ const persistentMessageHandler = this.createPersistentMessageHandler(context);
1387
2022
  const messageHandler = (event) => {
1388
- const message = JSON.parse(event.data);
1389
- this.debug(`WebSocket message received:`, message);
1390
- if (!(contextString(context) in this.checks)) {
1391
- this.checks[contextString(context)] = {};
1392
- }
1393
- (message.flags ?? []).forEach((flag) => {
1394
- const flagCheck = CheckFlagReturnFromJSON(flag);
1395
- const contextStr = contextString(context);
1396
- if (this.checks[contextStr] === void 0) {
1397
- this.checks[contextStr] = {};
1398
- }
1399
- this.checks[contextStr][flagCheck.flag] = flagCheck;
1400
- this.debug(`WebSocket flag update:`, {
1401
- flag: flagCheck.flag,
1402
- value: flagCheck.value,
1403
- flagCheck
1404
- });
1405
- if (typeof flagCheck.featureUsageEvent === "string") {
1406
- this.updateFeatureUsageEventMap(flagCheck);
1407
- }
1408
- if (this.flagCheckListeners[flag.flag]?.size > 0 || this.flagValueListeners[flag.flag]?.size > 0) {
1409
- this.submitFlagCheckEvent(flagCheck.flag, flagCheck, context);
1410
- }
1411
- this.notifyFlagCheckListeners(flag.flag, flagCheck);
1412
- this.notifyFlagValueListeners(flag.flag, flagCheck.value);
1413
- });
1414
- this.flushContextDependentEventQueue();
1415
- this.setIsPending(false);
2023
+ persistentMessageHandler(event);
1416
2024
  if (!resolved) {
1417
2025
  resolved = true;
1418
2026
  resolve();
1419
2027
  }
1420
2028
  };
1421
2029
  socket.addEventListener("message", messageHandler);
2030
+ this.currentWebSocket = socket;
1422
2031
  const clientVersion = this.additionalHeaders["X-Schematic-Client-Version"] ?? `schematic-js@${version}`;
1423
2032
  const messagePayload = {
1424
2033
  apiKey: this.apiKey,
@@ -1463,12 +2072,31 @@ var Schematic = class {
1463
2072
  getFlagCheck = (flagKey) => {
1464
2073
  const contextStr = contextString(this.context);
1465
2074
  const checks = this.checks[contextStr] ?? {};
1466
- return checks[flagKey];
2075
+ const check = checks[flagKey];
2076
+ if (check !== void 0) {
2077
+ return check;
2078
+ }
2079
+ if (flagKey in this.flagCheckDefaults || flagKey in this.flagValueDefaults) {
2080
+ return this.resolveFallbackCheckFlagReturn(
2081
+ flagKey,
2082
+ void 0,
2083
+ "Default value used"
2084
+ );
2085
+ }
2086
+ return void 0;
1467
2087
  };
1468
2088
  // flagValues state
1469
2089
  getFlagValue = (flagKey) => {
1470
- const check = this.getFlagCheck(flagKey);
1471
- return check?.value;
2090
+ const contextStr = contextString(this.context);
2091
+ const checks = this.checks[contextStr] ?? {};
2092
+ const check = checks[flagKey];
2093
+ if (check?.value !== void 0) {
2094
+ return check.value;
2095
+ }
2096
+ if (flagKey in this.flagCheckDefaults || flagKey in this.flagValueDefaults) {
2097
+ return this.resolveFallbackValue(flagKey);
2098
+ }
2099
+ return void 0;
1472
2100
  };
1473
2101
  /** Register an event listener that will be notified with the boolean value for a given flag when this value changes */
1474
2102
  addFlagValueListener = (flagKey, listener) => {
@@ -1526,7 +2154,17 @@ var Schematic = class {
1526
2154
  { value }
1527
2155
  );
1528
2156
  }
1529
- listeners.forEach((listener) => notifyFlagValueListener(listener, value));
2157
+ listeners.forEach((listener, index) => {
2158
+ this.debug(`Calling listener ${index} for flag ${flagKey}`, {
2159
+ flagKey,
2160
+ value
2161
+ });
2162
+ notifyFlagValueListener(listener, value);
2163
+ this.debug(`Listener ${index} for flag ${flagKey} completed`, {
2164
+ flagKey,
2165
+ value
2166
+ });
2167
+ });
1530
2168
  };
1531
2169
  };
1532
2170
  var notifyPendingListener = (listener, value) => {
@@ -1555,7 +2193,7 @@ var notifyFlagValueListener = (listener, value) => {
1555
2193
  var import_vue = require("vue");
1556
2194
 
1557
2195
  // src/version.ts
1558
- var version2 = "0.1.0";
2196
+ var version2 = "1.2.16";
1559
2197
 
1560
2198
  // src/context/schematic.ts
1561
2199
  var SchematicSymbol = Symbol("schematic");