@configdirector/react-native-sdk 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,6 +10,16 @@ React Native SDK for [ConfigDirector](https://www.configdirector.com), remote co
10
10
  npm install --save @configdirector/react-native-sdk
11
11
  ```
12
12
 
13
+ Install `@react-native-async-storage/async-storage` as well, so the SDK can keep the same client
14
+ session for six hours across app starts. It is an optional peer dependency and works in Expo Go.
15
+ Without it, every app start begins a new client session and the SDK logs a warning.
16
+
17
+ ```bash
18
+ npm install --save @react-native-async-storage/async-storage
19
+ # or in an Expo project
20
+ npx expo install @react-native-async-storage/async-storage
21
+ ```
22
+
13
23
  ## Retrieve a value
14
24
 
15
25
  ```tsx
@@ -74,6 +84,10 @@ package's files are `.mjs` files, which the `jest-expo` and `@react-native/jest-
74
84
  transform, so a Jest setup must let `transformIgnorePatterns` transform both packages and add a
75
85
  `transform` entry for `.mjs` files; the documentation shows the configuration for both presets.
76
86
  `jest-expo` requires `@react-native/jest-preset` as a peer dependency, so install it in either case.
87
+ The test client never touches AsyncStorage. A test that builds a real client instead, through
88
+ `createClient` or a provider given an `sdkKey`, logs the warning that AsyncStorage is missing and
89
+ starts a new client session under either preset; add `@babel/plugin-transform-dynamic-import` to the
90
+ Babel test environment for that client to use the AsyncStorage Jest mock instead.
77
91
 
78
92
  ## Documentation
79
93
 
@@ -1,4 +1,4 @@
1
- import { i as ConfigDirectorContext, n as ConfigDirectorClient, o as ConfigDirectorLogger } from "./types-fcPMBgiX.mjs";
1
+ import { i as ConfigDirectorContext, n as ConfigDirectorClient, o as ConfigDirectorLogger } from "./types-P1hoU2Ru.mjs";
2
2
 
3
3
  //#region ../shared/src/errors.d.ts
4
4
  declare class ConfigDirectorConnectionError extends Error {
@@ -102,7 +102,8 @@ interface TestClient {
102
102
  failContextUpdate(): void;
103
103
  /**
104
104
  * The context of every `initialize` and `updateContext` call, in call order. `initialize` without
105
- * a context records an empty context. Resumes are not recorded.
105
+ * a context records an empty context, and so does a call whose context has an invalid kind, since
106
+ * the client connects without it. Resumes are not recorded.
106
107
  */
107
108
  readonly contextUpdates: ConfigDirectorContext[];
108
109
  }
@@ -1,4 +1,4 @@
1
- import { c as ConfigDirectorConnectionError, d as _defineProperty, l as ConfigDirectorValidationError, o as createDefaultLogger, r as DefaultConfigDirectorClient, t as INSTALLED_TEST_CLIENT_SLOT, u as Emitter } from "./installedTestClient-CxGEAMOE.mjs";
1
+ import { c as ConfigDirectorConnectionError, d as _defineProperty, l as ConfigDirectorValidationError, o as createDefaultLogger, r as DefaultConfigDirectorClient, t as INSTALLED_TEST_CLIENT_SLOT, u as Emitter } from "./installedTestClient-LWv5ji8J.mjs";
2
2
  //#region ../shared/src/testing/values.ts
3
3
  const encodeTestValue$1 = (key, value) => {
4
4
  if (key.trim().length == 0) throw new ConfigDirectorValidationError("Invalid config key. The key of a test value cannot be empty.");
@@ -288,6 +288,7 @@ const TEST_CLIENT_SDK_KEY = "test-client";
288
288
  const noopTelemetryClient = {
289
289
  updateContext: async () => {},
290
290
  evaluatedConfig: () => {},
291
+ recordInvalidContext: () => {},
291
292
  close: async () => {}
292
293
  };
293
294
  var InMemoryTestClient = class {
@@ -391,7 +392,7 @@ const installTestClient = (testClient) => {
391
392
  const createTestClient = (options) => {
392
393
  return createTestClient$1({
393
394
  sdkName: "react-native-sdk",
394
- sdkVersion: "1.6.0"
395
+ sdkVersion: "1.7.0"
395
396
  }, options);
396
397
  };
397
398
  //#endregion
@@ -1,4 +1,4 @@
1
- import { a as ConfigDirectorLogMessageDecorator, c as ConfigValueType, i as ConfigDirectorContext, l as ConnectionMode, n as ConfigDirectorClient, o as ConfigDirectorLogger, r as ConfigDirectorClientOptions, s as ConfigDirectorLoggingLevel, t as ClientHooks } from "./types-fcPMBgiX.mjs";
1
+ import { a as ConfigDirectorLogMessageDecorator, c as ConfigValueType, i as ConfigDirectorContext, l as ConnectionMode, n as ConfigDirectorClient, o as ConfigDirectorLogger, r as ConfigDirectorClientOptions, s as ConfigDirectorLoggingLevel, t as ClientHooks } from "./types-P1hoU2Ru.mjs";
2
2
  import { Component, PropsWithChildren } from "react";
3
3
  import * as _$react_jsx_runtime0 from "react/jsx-runtime";
4
4
 
@@ -1,4 +1,4 @@
1
- import { a as CLIENT_BASE_URL, d as _defineProperty, i as fetchWithTimeout, n as readInstalledTestClient, o as createDefaultLogger, r as DefaultConfigDirectorClient, s as isFetchErrorFatal } from "./installedTestClient-CxGEAMOE.mjs";
1
+ import { a as CLIENT_BASE_URL, d as _defineProperty, i as fetchWithTimeout, n as readInstalledTestClient, o as createDefaultLogger, r as DefaultConfigDirectorClient, s as isFetchErrorFatal } from "./installedTestClient-LWv5ji8J.mjs";
2
2
  import React, { Component, useContext as useContext$1 } from "react";
3
3
  import { AppState, NativeModules } from "react-native";
4
4
  import { sha256 } from "@noble/hashes/sha2.js";
@@ -285,6 +285,63 @@ var EvaluatedConfigEventPreprocessor = class {
285
285
  };
286
286
  }
287
287
  };
288
+ var InvalidContextSubmissions = class {
289
+ constructor() {
290
+ _defineProperty(this, "countsByKindSample", /* @__PURE__ */ new Map());
291
+ _defineProperty(this, "countBeyondSampleLimit", 0);
292
+ _defineProperty(this, "startTime", void 0);
293
+ }
294
+ record(kindSample) {
295
+ var _this$startTime;
296
+ (_this$startTime = this.startTime) !== null && _this$startTime !== void 0 || (this.startTime = /* @__PURE__ */ new Date());
297
+ return this.add(kindSample, 1);
298
+ }
299
+ absorb(entries) {
300
+ for (const entry of entries) {
301
+ if (this.startTime === void 0 || entry.startTime < this.startTime) this.startTime = entry.startTime;
302
+ this.add(entry.event.kindSample, entry.count);
303
+ }
304
+ }
305
+ add(kindSample, count) {
306
+ if (kindSample !== void 0) {
307
+ const knownCount = this.countsByKindSample.get(kindSample);
308
+ if (knownCount !== void 0) {
309
+ this.countsByKindSample.set(kindSample, knownCount + count);
310
+ return "known-kind-sample";
311
+ }
312
+ if (this.countsByKindSample.size < 20) {
313
+ this.countsByKindSample.set(kindSample, count);
314
+ return "new-kind-sample";
315
+ }
316
+ }
317
+ const firstBeyondSampleLimit = this.countBeyondSampleLimit === 0;
318
+ this.countBeyondSampleLimit += count;
319
+ return firstBeyondSampleLimit ? "first-beyond-sample-limit" : "beyond-sample-limit";
320
+ }
321
+ takeSnapshot() {
322
+ var _this$startTime2;
323
+ const endTime = /* @__PURE__ */ new Date();
324
+ const startTime = (_this$startTime2 = this.startTime) !== null && _this$startTime2 !== void 0 ? _this$startTime2 : endTime;
325
+ const entryOf = (count, kindSample) => ({
326
+ startTime,
327
+ endTime,
328
+ count,
329
+ event: {
330
+ reason: "invalid-context-kind",
331
+ kindSample
332
+ }
333
+ });
334
+ const sampledEntries = Array.from(this.countsByKindSample, ([kindSample, count]) => entryOf(count, kindSample));
335
+ const entries = this.countBeyondSampleLimit > 0 ? [...sampledEntries, entryOf(this.countBeyondSampleLimit)] : sampledEntries;
336
+ this.clear();
337
+ return entries;
338
+ }
339
+ clear() {
340
+ this.countsByKindSample.clear();
341
+ this.countBeyondSampleLimit = 0;
342
+ this.startTime = void 0;
343
+ }
344
+ };
288
345
  //#endregion
289
346
  //#region ../js-client-core/src/telemetry/ClientTelemetryEventCollector.ts
290
347
  var ClientTelemetryEventCollector = class extends TelemetryEventCollector {
@@ -293,6 +350,7 @@ var ClientTelemetryEventCollector = class extends TelemetryEventCollector {
293
350
  _defineProperty(this, "evaluationEventSnapshotPreprocessor", void 0);
294
351
  _defineProperty(this, "reporter", void 0);
295
352
  _defineProperty(this, "_context", void 0);
353
+ _defineProperty(this, "invalidContextSubmissions", new InvalidContextSubmissions());
296
354
  this.reporter = new ClientEventReporter(options);
297
355
  this.evaluationEventSnapshotPreprocessor = new EvaluatedConfigEventPreprocessor(options.valueIdGenerator);
298
356
  }
@@ -306,6 +364,12 @@ var ClientTelemetryEventCollector = class extends TelemetryEventCollector {
306
364
  if (!this.collectEvents) return;
307
365
  this.evaluationEventQueue.push(event);
308
366
  }
367
+ recordInvalidContext(kindSample) {
368
+ this.invalidContextSubmissions.record(kindSample);
369
+ }
370
+ absorbInvalidContextSubmissions(entries) {
371
+ this.invalidContextSubmissions.absorb(entries);
372
+ }
309
373
  async forceFlush() {
310
374
  this.cancelScheduledFlush();
311
375
  await this.flushAndScheduleNext();
@@ -320,7 +384,10 @@ var ClientTelemetryEventCollector = class extends TelemetryEventCollector {
320
384
  return await ((_this$reporter = this.reporter) === null || _this$reporter === void 0 ? void 0 : _this$reporter.report({
321
385
  context: this._context,
322
386
  discreteEvents: {},
323
- aggregatedEvents: { evaluatedConfig: this.aggregator.aggregate(evaluationSnapshot) },
387
+ aggregatedEvents: {
388
+ evaluatedConfig: this.aggregator.aggregate(evaluationSnapshot),
389
+ invalidContext: this.invalidContextSubmissions.takeSnapshot()
390
+ },
324
391
  droppedEvents: { evaluatedConfig: evaluationSnapshot.droppedCount }
325
392
  }));
326
393
  }
@@ -453,6 +520,36 @@ const resolveMetadata = (provided, logger) => {
453
520
  return metadata;
454
521
  };
455
522
  //#endregion
523
+ //#region src/instanceIdStorage.ts
524
+ const lookUpAsyncStorage = async () => {
525
+ try {
526
+ const { default: storage } = await import("@react-native-async-storage/async-storage");
527
+ return {
528
+ outcome: "installed",
529
+ storage
530
+ };
531
+ } catch {
532
+ return { outcome: "not installed" };
533
+ }
534
+ };
535
+ const asyncStorageInstanceIdStorage = (logger) => {
536
+ const asyncStorageLookup = lookUpAsyncStorage().then((lookup) => {
537
+ if (lookup.outcome === "not installed") logger.warn("The ConfigDirector SDK could not find @react-native-async-storage/async-storage, so every app start counts as a new client session. Install it for the SDK to keep the client session for six hours across app starts.");
538
+ return lookup;
539
+ });
540
+ const installedAsyncStorage = async () => {
541
+ const lookup = await asyncStorageLookup;
542
+ switch (lookup.outcome) {
543
+ case "installed": return lookup.storage;
544
+ case "not installed": throw new Error("@react-native-async-storage/async-storage is not installed");
545
+ }
546
+ };
547
+ return {
548
+ getItem: async (key) => (await installedAsyncStorage()).getItem(key),
549
+ setItem: async (key, value) => (await installedAsyncStorage()).setItem(key, value)
550
+ };
551
+ };
552
+ //#endregion
456
553
  //#region src/utf8Encoder.ts
457
554
  /**
458
555
  * Encodes a string to a UTF-8 byte sequence.
@@ -607,6 +704,9 @@ var ReactNativeTelemetryClient = class {
607
704
  evaluatedConfig(event) {
608
705
  this.collector.evaluatedConfig(this.sanitizeEvaluatedConfigEvent(event));
609
706
  }
707
+ recordInvalidContext(kindSample) {
708
+ this.collector.recordInvalidContext(kindSample);
709
+ }
610
710
  async close() {
611
711
  await this.collector.close();
612
712
  }
@@ -646,7 +746,7 @@ const createClient = (clientSdkKey, clientOptions) => {
646
746
  var _clientOptions$logger, _clientOptions$connec;
647
747
  const sdkIdentity = {
648
748
  sdkName: "react-native-sdk",
649
- sdkVersion: "1.6.0"
749
+ sdkVersion: "1.7.0"
650
750
  };
651
751
  const logger = (_clientOptions$logger = clientOptions === null || clientOptions === void 0 ? void 0 : clientOptions.logger) !== null && _clientOptions$logger !== void 0 ? _clientOptions$logger : createConsoleLogger("warn");
652
752
  const metadata = resolveMetadata(clientOptions === null || clientOptions === void 0 ? void 0 : clientOptions.metadata, logger);
@@ -663,7 +763,8 @@ const createClient = (clientSdkKey, clientOptions) => {
663
763
  metadata
664
764
  }, {
665
765
  fetch: reactNativeStreamingFetch,
666
- urlFactory
766
+ urlFactory,
767
+ instanceIdStorage: asyncStorageInstanceIdStorage(logger)
667
768
  });
668
769
  };
669
770
  //#endregion
@@ -438,11 +438,15 @@ var StreamingTransport = class {
438
438
  _defineProperty(this, "eventEmitter", new Emitter());
439
439
  _defineProperty(this, "url", void 0);
440
440
  _defineProperty(this, "timeoutTimer", void 0);
441
+ _defineProperty(this, "connectionGeneration", 0);
441
442
  this.options = options;
442
443
  this.logger = options.logger;
443
444
  this.url = options.resolveUrl("client/sse/v1", options.baseUrl);
444
445
  }
445
446
  async connect(context, timeout) {
447
+ const generation = ++this.connectionGeneration;
448
+ const clientSession = await this.options.clientSession;
449
+ if (generation !== this.connectionGeneration) return this;
446
450
  if (this.eventSource) this.close();
447
451
  const eventSourcePromise = new Promise((resolve, reject) => {
448
452
  this.eventSource = createEventSourceClient({
@@ -454,7 +458,8 @@ var StreamingTransport = class {
454
458
  givenContext: context,
455
459
  metaContext: this.options.metaContext,
456
460
  clientSdkKey: this.options.clientSdkKey,
457
- instanceId: this.options.instanceId
461
+ instanceId: clientSession.instanceId,
462
+ ephemeral: clientSession.ephemeral
458
463
  }),
459
464
  onMessage: ({ data }) => {
460
465
  this.dispatchMessage(data);
@@ -520,6 +525,7 @@ var StreamingTransport = class {
520
525
  }
521
526
  close() {
522
527
  var _this$eventSource;
528
+ this.connectionGeneration++;
523
529
  (_this$eventSource = this.eventSource) === null || _this$eventSource === void 0 || _this$eventSource.close();
524
530
  clearTimeout(this.timeoutTimer);
525
531
  }
@@ -783,10 +789,11 @@ const generateInstanceId = () => {
783
789
  });
784
790
  };
785
791
  //#endregion
786
- //#region ../js-client-core/src/instance-id.ts
792
+ //#region ../js-client-core/src/client-session.ts
787
793
  const STORAGE_KEY = "configdirector-sdk:instance-id";
788
794
  const RECORD_VERSION = 1;
789
795
  const MAX_AGE_MS = 360 * 60 * 1e3;
796
+ const STORAGE_DEADLINE_MS = 1e3;
790
797
  const CHECKSUM_SALT = "configdirector.instanceId.v1";
791
798
  const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
792
799
  const fnv1a = (input, offsetBasis) => {
@@ -802,7 +809,7 @@ const checksumFor = (id, createdAt) => {
802
809
  const low = fnv1a(payload, 2166136261);
803
810
  return fnv1a(`${low}|${payload}`, 16777619).toString(16).padStart(8, "0") + low.toString(16).padStart(8, "0");
804
811
  };
805
- const getLocalStorage = () => {
812
+ const localStorageIfAvailable = () => {
806
813
  try {
807
814
  var _localStorage;
808
815
  return (_localStorage = globalThis.localStorage) !== null && _localStorage !== void 0 ? _localStorage : void 0;
@@ -818,34 +825,37 @@ const isUsableRecord = (record) => {
818
825
  if (!UUID_V4_PATTERN.test(id)) return false;
819
826
  return checksum === checksumFor(id, createdAt);
820
827
  };
821
- const readStoredInstanceId = (storage, logger) => {
828
+ const readStoredInstanceId = async (storage, logger) => {
822
829
  let raw;
823
830
  try {
824
- raw = storage.getItem(STORAGE_KEY);
831
+ raw = await storage.getItem(STORAGE_KEY);
825
832
  } catch (error) {
826
- logger === null || logger === void 0 || logger.debug("[ConfigDirectorClient] Could not read the stored instance id, generating a new one", error);
827
- return;
833
+ logger.debug("[ConfigDirectorClient] Could not read the stored instance id, using an ephemeral one", error);
834
+ return { outcome: "failed" };
828
835
  }
829
- if (!raw) return;
836
+ if (typeof raw !== "string" || raw === "") return { outcome: "nothing usable" };
830
837
  let record;
831
838
  try {
832
839
  record = JSON.parse(raw);
833
840
  } catch {
834
- logger === null || logger === void 0 || logger.debug("[ConfigDirectorClient] The stored instance id is not valid JSON, generating a new one");
835
- return;
841
+ logger.debug("[ConfigDirectorClient] The stored instance id is not valid JSON, generating a new one");
842
+ return { outcome: "nothing usable" };
836
843
  }
837
844
  if (!isUsableRecord(record)) {
838
- logger === null || logger === void 0 || logger.debug("[ConfigDirectorClient] The stored instance id failed validation, generating a new one");
839
- return;
845
+ logger.debug("[ConfigDirectorClient] The stored instance id failed validation, generating a new one");
846
+ return { outcome: "nothing usable" };
840
847
  }
841
848
  const age = Date.now() - record.createdAt;
842
849
  if (age < 0 || age >= MAX_AGE_MS) {
843
- logger === null || logger === void 0 || logger.debug("[ConfigDirectorClient] The stored instance id has expired, generating a new one");
844
- return;
850
+ logger.debug("[ConfigDirectorClient] The stored instance id has expired, generating a new one");
851
+ return { outcome: "nothing usable" };
845
852
  }
846
- return record.id;
853
+ return {
854
+ outcome: "stored",
855
+ instanceId: record.id
856
+ };
847
857
  };
848
- const storeInstanceId = (storage, id, logger) => {
858
+ const persistInstanceId = async (storage, id, logger) => {
849
859
  const createdAt = Date.now();
850
860
  const record = {
851
861
  v: RECORD_VERSION,
@@ -854,19 +864,54 @@ const storeInstanceId = (storage, id, logger) => {
854
864
  checksum: checksumFor(id, createdAt)
855
865
  };
856
866
  try {
857
- storage.setItem(STORAGE_KEY, JSON.stringify(record));
867
+ await storage.setItem(STORAGE_KEY, JSON.stringify(record));
868
+ return true;
858
869
  } catch (error) {
859
- logger === null || logger === void 0 || logger.debug("[ConfigDirectorClient] Could not persist the instance id", error);
870
+ logger.debug("[ConfigDirectorClient] Could not persist the instance id, using it as an ephemeral one", error);
871
+ return false;
872
+ }
873
+ };
874
+ const settleFromStorage = async (storage, provisionalId, deadline, logger) => {
875
+ const read = await readStoredInstanceId(storage, logger);
876
+ switch (read.outcome) {
877
+ case "stored": return { instanceId: read.instanceId };
878
+ case "failed": return {
879
+ instanceId: provisionalId,
880
+ ephemeral: true
881
+ };
882
+ case "nothing usable": break;
860
883
  }
884
+ if (deadline.passed) return {
885
+ instanceId: provisionalId,
886
+ ephemeral: true
887
+ };
888
+ return await persistInstanceId(storage, provisionalId, logger) ? { instanceId: provisionalId } : {
889
+ instanceId: provisionalId,
890
+ ephemeral: true
891
+ };
861
892
  };
862
- const resolveInstanceId = (logger) => {
863
- const storage = getLocalStorage();
864
- if (!storage) return generateInstanceId();
865
- const storedInstanceId = readStoredInstanceId(storage, logger);
866
- if (storedInstanceId) return storedInstanceId;
867
- const instanceId = generateInstanceId();
868
- storeInstanceId(storage, instanceId, logger);
869
- return instanceId;
893
+ const resolveClientSession = (logger, storage = localStorageIfAvailable()) => {
894
+ const provisionalId = generateInstanceId();
895
+ if (!storage) {
896
+ logger.debug("[ConfigDirectorClient] No instance id storage is available, using an ephemeral instance id");
897
+ return Promise.resolve({
898
+ instanceId: provisionalId,
899
+ ephemeral: true
900
+ });
901
+ }
902
+ const deadline = { passed: false };
903
+ let deadlineTimer;
904
+ const fallbackAtDeadline = new Promise((resolve) => {
905
+ deadlineTimer = setTimeout(() => {
906
+ deadline.passed = true;
907
+ logger.warn(`[ConfigDirectorClient] The instance id storage did not respond within ${STORAGE_DEADLINE_MS}ms, using an ephemeral instance id`);
908
+ resolve({
909
+ instanceId: provisionalId,
910
+ ephemeral: true
911
+ });
912
+ }, STORAGE_DEADLINE_MS);
913
+ });
914
+ return Promise.race([settleFromStorage(storage, provisionalId, deadline, logger), fallbackAtDeadline]).finally(() => clearTimeout(deadlineTimer));
870
915
  };
871
916
  //#endregion
872
917
  //#region ../shared/src/transport/AbstractPollingTransport.ts
@@ -954,25 +999,33 @@ var PollingTransport = class extends AbstractPollingTransport {
954
999
  _defineProperty(this, "eventEmitter", new Emitter());
955
1000
  _defineProperty(this, "url", void 0);
956
1001
  _defineProperty(this, "lastUpdateTimestamp", void 0);
1002
+ _defineProperty(this, "connectionGeneration", 0);
957
1003
  this.options = options;
958
1004
  this.logger = options.logger;
959
1005
  this.url = options.resolveUrl("client/polling/v1", options.baseUrl);
960
1006
  this.pollingIntervalSeconds = (_options$pollingInter = options.pollingInterval) !== null && _options$pollingInter !== void 0 ? _options$pollingInter : 60;
961
1007
  }
962
1008
  async connect(context, timeout) {
1009
+ const generation = ++this.connectionGeneration;
963
1010
  this.clearPollingInterval();
1011
+ const clientSession = await this.options.clientSession;
1012
+ if (generation !== this.connectionGeneration) return this;
964
1013
  try {
965
- await this.fetchConfigs(context, timeout);
1014
+ await this.fetchConfigs(context, timeout, clientSession);
966
1015
  } finally {
967
1016
  if (!this.fatalError) this.schedulePollingInterval(() => {
968
- this.fetchConfigs(context, timeout).catch((error) => {
1017
+ this.fetchConfigs(context, timeout, clientSession).catch((error) => {
969
1018
  this.logger.error("[PollingTransport] Error during polling:", error);
970
1019
  });
971
1020
  });
972
1021
  }
973
1022
  return this;
974
1023
  }
975
- async fetchConfigs(context, timeout) {
1024
+ close() {
1025
+ this.connectionGeneration++;
1026
+ super.close();
1027
+ }
1028
+ async fetchConfigs(context, timeout, clientSession) {
976
1029
  if (this.fatalError) {
977
1030
  this.logger.warn("[PollingTransport] There was a prior unrecoverable error. Ignoring attempt to reconnect.");
978
1031
  return;
@@ -985,7 +1038,8 @@ var PollingTransport = class extends AbstractPollingTransport {
985
1038
  givenContext: context,
986
1039
  metaContext: this.options.metaContext,
987
1040
  clientSdkKey: this.options.clientSdkKey,
988
- instanceId: this.options.instanceId,
1041
+ instanceId: clientSession.instanceId,
1042
+ ephemeral: clientSession.ephemeral,
989
1043
  lastUpdateTimestamp: this.lastUpdateTimestamp
990
1044
  })
991
1045
  }, this.logger);
@@ -1032,6 +1086,46 @@ const readHost = () => read((globals) => {
1032
1086
  return (_globals$location = globals.location) === null || _globals$location === void 0 ? void 0 : _globals$location.host;
1033
1087
  });
1034
1088
  //#endregion
1089
+ //#region ../shared/src/context-kind.ts
1090
+ const CONTEXT_KIND_MAXIMUM_LENGTH = 64;
1091
+ const KIND_SAMPLE_MAXIMUM_CHARACTERS = 128;
1092
+ const isContextKindCharacter = (character) => character >= "a" && character <= "z" || character >= "0" && character <= "9" || character === "-" || character === "_";
1093
+ const isValidContextKind = (kind) => {
1094
+ if (kind.length < 1 || kind.length > CONTEXT_KIND_MAXIMUM_LENGTH) return false;
1095
+ for (const character of kind) if (!isContextKindCharacter(character)) return false;
1096
+ return true;
1097
+ };
1098
+ const isAcceptedKind = (kind) => kind === void 0 || kind === null || typeof kind === "string" && isValidContextKind(kind);
1099
+ const stringFormOf = (kind) => {
1100
+ if (typeof kind === "string") return kind;
1101
+ try {
1102
+ return String(kind);
1103
+ } catch {
1104
+ return Object.prototype.toString.call(kind);
1105
+ }
1106
+ };
1107
+ const firstCharactersOf = (text, maximumCharacters) => {
1108
+ let sample = "";
1109
+ let characterCount = 0;
1110
+ for (const character of text) {
1111
+ if (characterCount === maximumCharacters) break;
1112
+ sample += character;
1113
+ characterCount += 1;
1114
+ }
1115
+ return sample;
1116
+ };
1117
+ const admitContext = (context) => {
1118
+ const kind = context === null || context === void 0 ? void 0 : context.kind;
1119
+ if (isAcceptedKind(kind)) return {
1120
+ outcome: "admitted",
1121
+ context
1122
+ };
1123
+ return {
1124
+ outcome: "ignored",
1125
+ kindSample: firstCharactersOf(stringFormOf(kind), KIND_SAMPLE_MAXIMUM_CHARACTERS)
1126
+ };
1127
+ };
1128
+ //#endregion
1035
1129
  //#region ../js-client-core/src/DefaultConfigDirectorClient.ts
1036
1130
  const MAX_EXPONENTIAL_DELAY = 9;
1037
1131
  var DefaultConfigDirectorClient = class {
@@ -1052,9 +1146,9 @@ var DefaultConfigDirectorClient = class {
1052
1146
  _defineProperty(this, "readyResolve", void 0);
1053
1147
  _defineProperty(this, "currentContext", void 0);
1054
1148
  _defineProperty(this, "connectionMode", void 0);
1055
- _defineProperty(this, "instanceId", void 0);
1149
+ _defineProperty(this, "clientSession", void 0);
1056
1150
  this.logger = (_clientOptions$logger = clientOptions === null || clientOptions === void 0 ? void 0 : clientOptions.logger) !== null && _clientOptions$logger !== void 0 ? _clientOptions$logger : createDefaultLogger();
1057
- this.instanceId = resolveInstanceId(this.logger);
1151
+ this.clientSession = resolveClientSession(this.logger, internalClientOptions === null || internalClientOptions === void 0 ? void 0 : internalClientOptions.instanceIdStorage);
1058
1152
  this.timeout = (_clientOptions$connec = clientOptions === null || clientOptions === void 0 || (_clientOptions$connec2 = clientOptions.connection) === null || _clientOptions$connec2 === void 0 ? void 0 : _clientOptions$connec2.timeout) !== null && _clientOptions$connec !== void 0 ? _clientOptions$connec : 3e3;
1059
1153
  this.validateSdkKeyPresence(clientSdkKey);
1060
1154
  const urlFactory = (_internalClientOption = internalClientOptions === null || internalClientOptions === void 0 ? void 0 : internalClientOptions.urlFactory) !== null && _internalClientOption !== void 0 ? _internalClientOption : defaultUrlFactory;
@@ -1111,7 +1205,7 @@ var DefaultConfigDirectorClient = class {
1111
1205
  buildTransport(clientSdkKey, sdkOptions, baseUrl, urlFactory, clientOptions, internalClientOptions) {
1112
1206
  var _internalClientOption3, _clientOptions$connec6;
1113
1207
  return new (this.getTransportConstructor(this.connectionMode))({
1114
- instanceId: this.instanceId,
1208
+ clientSession: this.clientSession,
1115
1209
  clientSdkKey,
1116
1210
  baseUrl,
1117
1211
  resolveUrl: urlFactory,
@@ -1138,10 +1232,23 @@ var DefaultConfigDirectorClient = class {
1138
1232
  }
1139
1233
  async initialize(context) {
1140
1234
  if (!this.hasReceivedConfigSet) this.initializing = true;
1141
- await this.connectToTransport(context, "initialization");
1235
+ await this.submitContext(context, "initialization");
1142
1236
  }
1143
1237
  async updateContext(context) {
1144
- await this.connectToTransport(context, "context update");
1238
+ await this.submitContext(context, "context update");
1239
+ }
1240
+ async submitContext(context, submission) {
1241
+ await this.connectToTransport(this.contextToConnectWith(context, submission), submission);
1242
+ }
1243
+ contextToConnectWith(suppliedContext, submission) {
1244
+ const admission = admitContext(suppliedContext);
1245
+ switch (admission.outcome) {
1246
+ case "admitted": return admission.context;
1247
+ case "ignored":
1248
+ this.logger.error(`[ConfigDirectorClient] Ignored the context supplied for ${submission} because its kind '${admission.kindSample}' is invalid; connecting as if no context was supplied. A context kind has 1 to 64 characters, using only lowercase letters, digits, '-' and '_'.`);
1249
+ this.telemetryClient.recordInvalidContext(admission.kindSample);
1250
+ return;
1251
+ }
1145
1252
  }
1146
1253
  get context() {
1147
1254
  return this.currentContext;
@@ -1161,7 +1268,7 @@ var DefaultConfigDirectorClient = class {
1161
1268
  const startTime = (/* @__PURE__ */ new Date()).getTime();
1162
1269
  await this.transport.connect(context !== null && context !== void 0 ? context : {}, this.timeout, caller);
1163
1270
  this.currentContext = context;
1164
- this.telemetryClient.updateContext(context);
1271
+ this.telemetryClient.updateContext(context).catch((error) => this.logger.warn("[ConfigDirectorClient] Telemetry could not update the context: ", error));
1165
1272
  this.emit("contextUpdated", { context });
1166
1273
  const elapsedTime = (/* @__PURE__ */ new Date()).getTime() - startTime;
1167
1274
  const remainingTimeout = this.timeout - elapsedTime;
@@ -1323,7 +1430,7 @@ var DefaultConfigDirectorClient = class {
1323
1430
  this.logger.debug("[ConfigDirectorClient] close() has been called, closing connection to server");
1324
1431
  clearTimeout(this.timeoutTimer);
1325
1432
  (_this$readyResolve2 = this.readyResolve) === null || _this$readyResolve2 === void 0 || _this$readyResolve2.call(this, true);
1326
- this.telemetryClient.close();
1433
+ this.telemetryClient.close().catch((error) => this.logger.warn("[ConfigDirectorClient] Telemetry could not close: ", error));
1327
1434
  this.transport.close();
1328
1435
  this.ready = false;
1329
1436
  this.initializing = false;
@@ -33,27 +33,40 @@ type ConfigEvaluation = {
33
33
  context?: ConfigDirectorContext;
34
34
  };
35
35
  /**
36
- * The user's context to be sent to ConfigDirector. This context will be used for targeting
37
- * rules evaluation.
36
+ * The context to be sent to ConfigDirector: the user or other entity, such as a device or an
37
+ * organization, that configs are evaluated for. This context will be used for targeting rules
38
+ * evaluation.
38
39
  */
39
40
  type ConfigDirectorContext = {
40
41
  /**
41
- * The user's identifier. This should be a value that uniquely identifies an application
42
- * user.
42
+ * The context's identifier. This should be a value that uniquely identifies the user or other
43
+ * entity among contexts of the same {@link kind}.
43
44
  * In the case of anonymous users, you could generate a UUID or alternatively not provide
44
45
  * the {@link id} and the SDK will generate a random UUID. However, keep in mind that this
45
- * value is used for segmenting users in percentage rollouts, and changes to the {@link id}
46
- * could result in the user being assigned to a different percentile.
46
+ * value is used for segmenting contexts in percentage rollouts, and changes to the {@link id}
47
+ * could result in the context being assigned to a different percentile.
47
48
  */
48
49
  id?: string;
49
50
  /**
50
- * The user's display name. This will be shown in the ConfigDirector dashboard and may be
51
+ * The kind of entity this context represents, such as `user`, `device`, `organization`,
52
+ * `tenant` or `listing`. You can use any other kind that fits your application. A kind has 1
53
+ * to 64 characters, using only lowercase letters (`a`-`z`), digits, `-` and `_`. It is used
54
+ * exactly as given, without trimming or changing its case.
55
+ *
56
+ * A context with an invalid kind is ignored as a whole: the SDK logs an error and evaluates
57
+ * as if no context had been provided.
58
+ *
59
+ * @default "user"
60
+ */
61
+ kind?: string;
62
+ /**
63
+ * The context's display name. This will be shown in the ConfigDirector dashboard and may be
51
64
  * used for targeting rules.
52
65
  */
53
66
  name?: string;
54
67
  /**
55
- * Any arbitrary traits for the current user. They will be shown in the ConfigDirector
56
- * dashboard and may be used for targeting rules.
68
+ * Any arbitrary traits for the context. They will be shown in the ConfigDirector dashboard
69
+ * and may be used for targeting rules.
57
70
  */
58
71
  traits?: {
59
72
  [key: string]: unknown;
@@ -202,17 +215,25 @@ interface ConfigDirectorClient extends EventProvider<ClientEvents> {
202
215
  * not attempt to re-connect and an error will be logged to the console or the provided
203
216
  * logger.
204
217
  *
205
- * @param context The current user's context to be used for evaluating targeting rules (optional).
218
+ * A context whose {@link ConfigDirectorContext.kind} is invalid is ignored as a whole: an error is
219
+ * logged, and the client connects as if no context had been provided.
220
+ *
221
+ * @param context The current context to be used for evaluating targeting rules (optional).
206
222
  */
207
223
  initialize(context?: ConfigDirectorContext): Promise<void>;
208
224
  /**
209
- * Updates the user's context and re-evaluates all config and flag values based on the new context.
225
+ * Updates the context and re-evaluates all config and flag values based on the new context.
226
+ *
227
+ * A context whose {@link ConfigDirectorContext.kind} is invalid is ignored as a whole: an error is
228
+ * logged, the previous context is no longer used, and the client connects as if no context had been
229
+ * provided.
210
230
  *
211
- * @param context The current user's context to be used for evaluating targeting rules (required).
231
+ * @param context The current context to be used for evaluating targeting rules (required).
212
232
  */
213
233
  updateContext(context: ConfigDirectorContext): Promise<void>;
214
234
  /**
215
- * Returns the current context the client is configured with, or undefined if there is no context.
235
+ * Returns the current context the client is configured with, or undefined if there is no context,
236
+ * including after a context with an invalid kind was ignored.
216
237
  *
217
238
  * Note that the value returned does not updated immediately after calling {@link updateContext}. Only
218
239
  * once the underlying connection was successful or timed out, the current context is updated. This is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@configdirector/react-native-sdk",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "React Native client SDK for ConfigDirector. ConfigDirector is a remote configuration and feature flag service.",
5
5
  "license": "MIT",
6
6
  "author": "ConfigDirector",
@@ -71,7 +71,9 @@
71
71
  },
72
72
  "devDependencies": {
73
73
  "@babel/core": "^7.29.0",
74
+ "@babel/plugin-transform-dynamic-import": "^7",
74
75
  "@eslint/js": "^10.0.1",
76
+ "@react-native-async-storage/async-storage": "^3.1.1",
75
77
  "@react-native/babel-preset": "^0.85.0",
76
78
  "@react-native/jest-preset": "^0.86.2",
77
79
  "@rollup/plugin-replace": "^6.0.3",
@@ -96,11 +98,15 @@
96
98
  "wtfnode": "^0.10.1"
97
99
  },
98
100
  "peerDependencies": {
101
+ "@react-native-async-storage/async-storage": ">=1.0.0",
99
102
  "expo": ">=52",
100
103
  "react": "*",
101
104
  "react-native": ">=0.75"
102
105
  },
103
106
  "peerDependenciesMeta": {
107
+ "@react-native-async-storage/async-storage": {
108
+ "optional": true
109
+ },
104
110
  "expo": {
105
111
  "optional": true
106
112
  }
@@ -130,7 +136,7 @@
130
136
  "^@eventsource/(.*)$": "<rootDir>/../eventsource/src/$1"
131
137
  },
132
138
  "transformIgnorePatterns": [
133
- "node_modules/(?!((jest-)?react-native|@react-native(-community)?|@noble)/)"
139
+ "node_modules/(?!((jest-)?react-native|@react-native(-community|-async-storage)?|@noble)/)"
134
140
  ]
135
141
  }
136
142
  }