@configdirector/react-native-sdk 1.8.0 → 1.9.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,9 +10,11 @@ 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.
13
+ Install `@react-native-async-storage/async-storage` as well, so the SDK recognizes the app install
14
+ across app starts. For a context without an `id`, ConfigDirector derives an identifier from an ID
15
+ the SDK stores for the install, so the context keeps that identifier, and counts as one monthly
16
+ active user, however often the app is opened. It is an optional peer dependency and works in Expo
17
+ Go. Without it, every app start looks like a new install and the SDK logs a warning.
16
18
 
17
19
  ```bash
18
20
  npm install --save @react-native-async-storage/async-storage
@@ -86,7 +88,7 @@ transform, so a Jest setup must let `transformIgnorePatterns` transform both pac
86
88
  `jest-expo` requires `@react-native/jest-preset` as a peer dependency, so install it in either case.
87
89
  The test client never touches AsyncStorage. A test that builds a real client instead, through
88
90
  `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
91
+ connects as a new install under either preset; add `@babel/plugin-transform-dynamic-import` to the
90
92
  Babel test environment for that client to use the AsyncStorage Jest mock instead.
91
93
 
92
94
  ## Documentation
@@ -1,4 +1,4 @@
1
- import { c as NativeValues, d as ConfigDirectorLogger, l as ConfigDirectorContext, n as ConfigDirectorClient, o as NativeJsonValue, s as NativeValue } from "./types-DoORnGTD.mjs";
1
+ import { c as NativeJsonValue, d as ConfigDirectorContext, i as ConfigDirectorClient, l as NativeValue, p as ConfigDirectorLogger, u as NativeValues } from "./types-BBQmjjf5.mjs";
2
2
 
3
3
  //#region ../shared/src/errors.d.ts
4
4
  declare class ConfigDirectorConnectionError extends Error {
@@ -1,4 +1,4 @@
1
- import { a as discardingTelemetryClient, d as Emitter, f as _defineProperty, l as ConfigDirectorConnectionError, o as createDefaultLogger, r as DefaultConfigDirectorClient, s as nativeValueConfigState, t as INSTALLED_TEST_CLIENT_SLOT, u as ConfigDirectorValidationError } from "./installedTestClient-CKjaEilj.mjs";
1
+ import { c as createDefaultLogger, d as ConfigDirectorConnectionError, f as ConfigDirectorValidationError, l as nativeValueConfigState, m as _defineProperty, p as Emitter, r as DefaultConfigDirectorClient, s as discardingTelemetryClient, t as INSTALLED_TEST_CLIENT_SLOT } from "./installedTestClient-BluKT8Qj.mjs";
2
2
  //#region ../js-client-core/src/testing/InMemoryConnection.ts
3
3
  const TEST_ENVIRONMENT = {
4
4
  environmentId: "test-environment",
@@ -295,7 +295,7 @@ const installTestClient = (testClient) => {
295
295
  const createTestClient = (options) => {
296
296
  return createTestClient$1({
297
297
  sdkName: "react-native-sdk",
298
- sdkVersion: "1.8.0"
298
+ sdkVersion: "1.9.0"
299
299
  }, options);
300
300
  };
301
301
  //#endregion
@@ -1,4 +1,4 @@
1
- import { a as OfflineValues, d as ConfigDirectorLogger, f as ConfigDirectorLoggingLevel, i as OfflineValue, l as ConfigDirectorContext, m as ConnectionMode, n as ConfigDirectorClient, p as ConfigValueType, r as ConfigDirectorClientOptions, t as ClientHooks, u as ConfigDirectorLogMessageDecorator } from "./types-DoORnGTD.mjs";
1
+ import { a as ConfigDirectorClientOptions, d as ConfigDirectorContext, f as ConfigDirectorLogMessageDecorator, g as ConnectionMode, h as ConfigValueType, i as ConfigDirectorClient, m as ConfigDirectorLoggingLevel, n as CachingProvider, o as OfflineValue, p as ConfigDirectorLogger, r as ClientHooks, s as OfflineValues, t as CachingOptions } from "./types-BBQmjjf5.mjs";
2
2
  import { Component, PropsWithChildren } from "react";
3
3
  import * as _$react_jsx_runtime0 from "react/jsx-runtime";
4
4
 
@@ -74,6 +74,12 @@ type ConfigDirectorProviderOptions = {
74
74
  context?: ConfigDirectorContext;
75
75
  logger?: ConfigDirectorLogger;
76
76
  hooks?: ClientHooks;
77
+ /**
78
+ * Options for the values the client keeps on the device between launches. The last values received
79
+ * are kept by default and served, with the evaluation reason `cached`, on the next launch of the same
80
+ * context until live values arrive.
81
+ */
82
+ caching?: CachingOptions;
77
83
  netInfoSubscribe?: NetInfoSubscribe;
78
84
  };
79
85
  /**
@@ -145,4 +151,4 @@ declare class ConfigDirectorReactContextError extends Error {
145
151
  //#region src/logger.d.ts
146
152
  declare const createConsoleLogger: (level?: ConfigDirectorLoggingLevel) => DefaultConsoleLogger;
147
153
  //#endregion
148
- export { type ClientStatus, type ConfigDirectorClient, type ConfigDirectorClientOptions, type ConfigDirectorContext, type ConfigDirectorLogger, type ConfigDirectorLoggingLevel, ConfigDirectorProvider, type ConfigDirectorProviderClientProps, type ConfigDirectorProviderOptions, type ConfigDirectorProviderProps, ConfigDirectorReactContextError, type ConnectionMode, type NetInfoSubscribe, type OfflineValue, type OfflineValues, createClient, createConsoleLogger, useClient, useConfigValue, useContext };
154
+ export { type CachingOptions, type CachingProvider, type ClientStatus, type ConfigDirectorClient, type ConfigDirectorClientOptions, type ConfigDirectorContext, type ConfigDirectorLogger, type ConfigDirectorLoggingLevel, ConfigDirectorProvider, type ConfigDirectorProviderClientProps, type ConfigDirectorProviderOptions, type ConfigDirectorProviderProps, ConfigDirectorReactContextError, type ConnectionMode, type NetInfoSubscribe, type OfflineValue, type OfflineValues, createClient, createConsoleLogger, useClient, useConfigValue, useContext };
@@ -1,7 +1,8 @@
1
- import { c as isFetchErrorFatal, f as _defineProperty, i as fetchWithTimeout, n as readInstalledTestClient, o as createDefaultLogger, r as DefaultConfigDirectorClient } from "./installedTestClient-CKjaEilj.mjs";
1
+ import { a as DEFAULT_CACHING_SIZE_LIMIT_BYTES, c as createDefaultLogger, i as CACHED_VALUES_STORAGE_KEY_PREFIX, m as _defineProperty, n as readInstalledTestClient, o as fetchWithTimeout, r as DefaultConfigDirectorClient, u as isFetchErrorFatal } from "./installedTestClient-BluKT8Qj.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";
5
+ import { bytesToHex } from "@noble/hashes/utils.js";
5
6
  import { jsx } from "react/jsx-runtime";
6
7
  //#region ../shared/src/telemetry/utils.ts
7
8
  const CONFIG_VALUE_MAX_LENGTH = 500;
@@ -520,8 +521,8 @@ const resolveMetadata = (provided, logger) => {
520
521
  return metadata;
521
522
  };
522
523
  //#endregion
523
- //#region src/instanceIdStorage.ts
524
- const lookUpAsyncStorage = async () => {
524
+ //#region src/asyncStorage.ts
525
+ const importAsyncStorage = async () => {
525
526
  try {
526
527
  const { default: storage } = await import("@react-native-async-storage/async-storage");
527
528
  return {
@@ -532,23 +533,10 @@ const lookUpAsyncStorage = async () => {
532
533
  return { outcome: "not installed" };
533
534
  }
534
535
  };
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
- };
536
+ const findAsyncStorage = (logger) => importAsyncStorage().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 looks like a new install to ConfigDirector, a context without an id gets a new identifier each time, and no values are kept on the device between app starts. Install it for the SDK to recognize the install across app starts and to serve the last values when the app starts without a network.");
538
+ return lookup;
539
+ });
552
540
  //#endregion
553
541
  //#region src/utf8Encoder.ts
554
542
  /**
@@ -590,6 +578,42 @@ const encodeManually = (str) => {
590
578
  return new Uint8Array(bytes);
591
579
  };
592
580
  //#endregion
581
+ //#region src/cachingProvider.ts
582
+ const storageKeyFor = (clientSdkKey) => CACHED_VALUES_STORAGE_KEY_PREFIX + bytesToHex(sha256(encodeUtf8(clientSdkKey)));
583
+ const asyncStorageCachingProvider = (asyncStorage) => (clientSdkKey) => {
584
+ const storageKey = storageKeyFor(clientSdkKey);
585
+ return {
586
+ read: async () => {
587
+ const lookup = await asyncStorage;
588
+ return lookup.outcome === "installed" ? lookup.storage.getItem(storageKey) : void 0;
589
+ },
590
+ write: async (value) => {
591
+ const lookup = await asyncStorage;
592
+ if (lookup.outcome === "installed") await lookup.storage.setItem(storageKey, value);
593
+ },
594
+ remove: async () => {
595
+ const lookup = await asyncStorage;
596
+ if (lookup.outcome === "installed") await lookup.storage.removeItem(storageKey);
597
+ },
598
+ sizeLimit: () => DEFAULT_CACHING_SIZE_LIMIT_BYTES
599
+ };
600
+ };
601
+ //#endregion
602
+ //#region src/instanceIdStorage.ts
603
+ const asyncStorageInstanceIdStorage = (asyncStorage) => {
604
+ const installedAsyncStorage = async () => {
605
+ const lookup = await asyncStorage;
606
+ switch (lookup.outcome) {
607
+ case "installed": return lookup.storage;
608
+ case "not installed": throw new Error("@react-native-async-storage/async-storage is not installed");
609
+ }
610
+ };
611
+ return {
612
+ getItem: async (key) => (await installedAsyncStorage()).getItem(key),
613
+ setItem: async (key, value) => (await installedAsyncStorage()).setItem(key, value)
614
+ };
615
+ };
616
+ //#endregion
593
617
  //#region src/reactNativeStreamingFetch.ts
594
618
  const abortError = (message) => {
595
619
  const err = new Error(message);
@@ -747,10 +771,11 @@ const createReactNativeClient = (clientSdkKey, clientOptions) => {
747
771
  var _clientOptions$logger;
748
772
  const sdkIdentity = {
749
773
  sdkName: "react-native-sdk",
750
- sdkVersion: "1.8.0"
774
+ sdkVersion: "1.9.0"
751
775
  };
752
776
  const logger = (_clientOptions$logger = clientOptions === null || clientOptions === void 0 ? void 0 : clientOptions.logger) !== null && _clientOptions$logger !== void 0 ? _clientOptions$logger : createConsoleLogger("warn");
753
777
  const metadata = resolveMetadata(clientOptions === null || clientOptions === void 0 ? void 0 : clientOptions.metadata, logger);
778
+ const asyncStorage = findAsyncStorage(logger);
754
779
  return new DefaultConfigDirectorClient((liveTelemetry) => new ReactNativeTelemetryClient({
755
780
  sdkKey: liveTelemetry.clientSdkKey,
756
781
  sdkIdentity,
@@ -765,7 +790,8 @@ const createReactNativeClient = (clientSdkKey, clientOptions) => {
765
790
  }, {
766
791
  fetch: reactNativeStreamingFetch,
767
792
  urlFactory,
768
- instanceIdStorage: asyncStorageInstanceIdStorage(logger)
793
+ instanceIdStorage: asyncStorageInstanceIdStorage(asyncStorage),
794
+ platformCachingProvider: asyncStorageCachingProvider(asyncStorage)
769
795
  });
770
796
  };
771
797
  //#endregion
@@ -785,7 +811,8 @@ const buildClient = (options) => {
785
811
  appVersion: options.appVersion
786
812
  },
787
813
  logger: (_options$logger = options.logger) !== null && _options$logger !== void 0 ? _options$logger : createConsoleLogger("warn"),
788
- hooks: options.hooks
814
+ hooks: options.hooks,
815
+ caching: options.caching
789
816
  });
790
817
  };
791
818
  const hasGivenClient = (props) => "client" in props && props.client !== void 0;
@@ -982,10 +982,34 @@ const generateInstanceId = () => {
982
982
  });
983
983
  };
984
984
  //#endregion
985
+ //#region ../js-client-core/src/browser-globals.ts
986
+ const read = (readValue) => {
987
+ try {
988
+ return readValue(globalThis);
989
+ } catch {
990
+ return;
991
+ }
992
+ };
993
+ const readUserAgent = () => read((globals) => {
994
+ var _globals$navigator;
995
+ return (_globals$navigator = globals.navigator) === null || _globals$navigator === void 0 ? void 0 : _globals$navigator.userAgent;
996
+ });
997
+ const readHost = () => read((globals) => {
998
+ var _globals$location;
999
+ return (_globals$location = globals.location) === null || _globals$location === void 0 ? void 0 : _globals$location.host;
1000
+ });
1001
+ const localStorageIfAvailable = () => {
1002
+ try {
1003
+ var _localStorage;
1004
+ return (_localStorage = globalThis.localStorage) !== null && _localStorage !== void 0 ? _localStorage : void 0;
1005
+ } catch {
1006
+ return;
1007
+ }
1008
+ };
1009
+ //#endregion
985
1010
  //#region ../js-client-core/src/client-session.ts
986
1011
  const STORAGE_KEY = "configdirector-sdk:instance-id";
987
1012
  const RECORD_VERSION = 1;
988
- const MAX_AGE_MS = 360 * 60 * 1e3;
989
1013
  const STORAGE_DEADLINE_MS = 1e3;
990
1014
  const CHECKSUM_SALT = "configdirector.instanceId.v1";
991
1015
  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}$/;
@@ -1002,14 +1026,6 @@ const checksumFor = (id, createdAt) => {
1002
1026
  const low = fnv1a(payload, 2166136261);
1003
1027
  return fnv1a(`${low}|${payload}`, 16777619).toString(16).padStart(8, "0") + low.toString(16).padStart(8, "0");
1004
1028
  };
1005
- const localStorageIfAvailable = () => {
1006
- try {
1007
- var _localStorage;
1008
- return (_localStorage = globalThis.localStorage) !== null && _localStorage !== void 0 ? _localStorage : void 0;
1009
- } catch {
1010
- return;
1011
- }
1012
- };
1013
1029
  const isUsableRecord = (record) => {
1014
1030
  if (!record || typeof record !== "object") return false;
1015
1031
  const { v, id, createdAt, checksum } = record;
@@ -1038,11 +1054,6 @@ const readStoredInstanceId = async (storage, logger) => {
1038
1054
  logger.debug("[ConfigDirectorClient] The stored instance id failed validation, generating a new one");
1039
1055
  return { outcome: "nothing usable" };
1040
1056
  }
1041
- const age = Date.now() - record.createdAt;
1042
- if (age < 0 || age >= MAX_AGE_MS) {
1043
- logger.debug("[ConfigDirectorClient] The stored instance id has expired, generating a new one");
1044
- return { outcome: "nothing usable" };
1045
- }
1046
1057
  return {
1047
1058
  outcome: "stored",
1048
1059
  instanceId: record.id
@@ -1262,23 +1273,6 @@ var PollingTransport = class extends AbstractPollingTransport {
1262
1273
  }
1263
1274
  };
1264
1275
  //#endregion
1265
- //#region ../js-client-core/src/browser-globals.ts
1266
- const read = (readValue) => {
1267
- try {
1268
- return readValue(globalThis);
1269
- } catch {
1270
- return;
1271
- }
1272
- };
1273
- const readUserAgent = () => read((globals) => {
1274
- var _globals$navigator;
1275
- return (_globals$navigator = globals.navigator) === null || _globals$navigator === void 0 ? void 0 : _globals$navigator.userAgent;
1276
- });
1277
- const readHost = () => read((globals) => {
1278
- var _globals$location;
1279
- return (_globals$location = globals.location) === null || _globals$location === void 0 ? void 0 : _globals$location.host;
1280
- });
1281
- //#endregion
1282
1276
  //#region ../shared/src/context-kind.ts
1283
1277
  const CONTEXT_KIND_MAXIMUM_LENGTH = 64;
1284
1278
  const KIND_SAMPLE_MAXIMUM_CHARACTERS = 128;
@@ -1319,15 +1313,190 @@ const admitContext = (context) => {
1319
1313
  };
1320
1314
  };
1321
1315
  //#endregion
1316
+ //#region ../shared/src/utf8-byte-length.ts
1317
+ const isHighSurrogate = (codeUnit) => codeUnit >= 55296 && codeUnit <= 56319;
1318
+ const isLowSurrogate = (codeUnit) => codeUnit >= 56320 && codeUnit <= 57343;
1319
+ const utf8ByteLength = (text) => {
1320
+ let bytes = 0;
1321
+ for (let i = 0; i < text.length; i++) {
1322
+ const codeUnit = text.charCodeAt(i);
1323
+ if (codeUnit <= 127) bytes += 1;
1324
+ else if (codeUnit <= 2047) bytes += 2;
1325
+ else if (isHighSurrogate(codeUnit) && i + 1 < text.length && isLowSurrogate(text.charCodeAt(i + 1))) {
1326
+ bytes += 4;
1327
+ i++;
1328
+ } else bytes += 3;
1329
+ }
1330
+ return bytes;
1331
+ };
1332
+ //#endregion
1333
+ //#region ../js-client-core/src/cached-values.ts
1334
+ const ENTRY_FORMAT_VERSION = 1;
1335
+ const DEFAULT_CONTEXT_KIND = "user";
1336
+ const CACHED_VALUES_STORAGE_KEY_PREFIX = "configdirector-sdk:cached-values:";
1337
+ const DEFAULT_CACHING_SIZE_LIMIT_BYTES = 256 * 1024;
1338
+ const planCaching = (connectionPlan, caching, platformProvider) => {
1339
+ var _caching$provider;
1340
+ if (connectionPlan.kind === "stay-offline") return {
1341
+ kind: "no cache",
1342
+ reason: "offline mode"
1343
+ };
1344
+ if ((caching === null || caching === void 0 ? void 0 : caching.enabled) === false) return {
1345
+ kind: "no cache",
1346
+ reason: "turned off"
1347
+ };
1348
+ const provider = (_caching$provider = caching === null || caching === void 0 ? void 0 : caching.provider) !== null && _caching$provider !== void 0 ? _caching$provider : platformProvider === null || platformProvider === void 0 ? void 0 : platformProvider(connectionPlan.clientSdkKey);
1349
+ if (!provider) return {
1350
+ kind: "no cache",
1351
+ reason: "no provider available"
1352
+ };
1353
+ return {
1354
+ kind: "cache",
1355
+ provider,
1356
+ clientSdkKey: connectionPlan.clientSdkKey
1357
+ };
1358
+ };
1359
+ const contextTagFor = (context, instanceId) => {
1360
+ var _context$kind;
1361
+ const contextKind = (_context$kind = context === null || context === void 0 ? void 0 : context.kind) !== null && _context$kind !== void 0 ? _context$kind : DEFAULT_CONTEXT_KIND;
1362
+ if ((context === null || context === void 0 ? void 0 : context.id) != null) return {
1363
+ contextKind,
1364
+ contextId: context.id
1365
+ };
1366
+ return {
1367
+ contextKind,
1368
+ anonymousInstanceId: instanceId
1369
+ };
1370
+ };
1371
+ const sameContextTag = (stored, launch) => {
1372
+ if (stored.contextKind !== launch.contextKind) return false;
1373
+ if ("contextId" in stored && "contextId" in launch) return stored.contextId === launch.contextId;
1374
+ if ("anonymousInstanceId" in stored && "anonymousInstanceId" in launch) return stored.anonymousInstanceId === launch.anonymousInstanceId;
1375
+ return false;
1376
+ };
1377
+ const decideCachedValuesAdoption = (cachedValues, launchContextTag, hasReceivedLiveValues) => {
1378
+ if (hasReceivedLiveValues) return "live values already arrived";
1379
+ if (!sameContextTag(cachedValues.contextTag, launchContextTag)) return "context mismatch";
1380
+ return "adopt";
1381
+ };
1382
+ const isContextTag = (candidate) => {
1383
+ if (!candidate || typeof candidate !== "object") return false;
1384
+ const { contextKind, contextId, anonymousInstanceId } = candidate;
1385
+ if (typeof contextKind !== "string") return false;
1386
+ return typeof contextId === "string" || typeof anonymousInstanceId === "string";
1387
+ };
1388
+ const isConfigStateMap = (candidate) => {
1389
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return false;
1390
+ return Object.values(candidate).every((config) => config && typeof config === "object" && typeof config.id === "string" && typeof config.key === "string");
1391
+ };
1392
+ const isEntryFor = (clientSdkKey, candidate) => {
1393
+ if (!candidate || typeof candidate !== "object") return false;
1394
+ const entry = candidate;
1395
+ return entry.formatVersion === ENTRY_FORMAT_VERSION && entry.clientSdkKey === clientSdkKey && isContextTag(entry.contextTag) && typeof entry.environmentId === "string" && typeof entry.projectId === "string" && isConfigStateMap(entry.configs) && typeof entry.receivedAt === "number";
1396
+ };
1397
+ var CachedValuesStore = class {
1398
+ constructor(provider, clientSdkKey, logger) {
1399
+ this.provider = provider;
1400
+ this.clientSdkKey = clientSdkKey;
1401
+ this.logger = logger;
1402
+ _defineProperty(this, "warned", /* @__PURE__ */ new Set());
1403
+ _defineProperty(this, "pendingRead", void 0);
1404
+ _defineProperty(this, "queuedCachedValues", void 0);
1405
+ _defineProperty(this, "writing", false);
1406
+ }
1407
+ read() {
1408
+ var _this$pendingRead;
1409
+ (_this$pendingRead = this.pendingRead) !== null && _this$pendingRead !== void 0 || (this.pendingRead = this.readEntry());
1410
+ return this.pendingRead;
1411
+ }
1412
+ replace(cachedValues) {
1413
+ this.queuedCachedValues = cachedValues;
1414
+ this.writeQueued();
1415
+ }
1416
+ async readEntry() {
1417
+ let raw;
1418
+ try {
1419
+ raw = await this.provider.read();
1420
+ } catch (error) {
1421
+ this.warnOnce("read failed", "Could not read the values kept on the device, starting without them:", error);
1422
+ return { outcome: "failed" };
1423
+ }
1424
+ if (raw == null || raw === "") return { outcome: "nothing stored" };
1425
+ const entry = this.parseEntry(raw);
1426
+ if (!entry) {
1427
+ this.warnOnce("unreadable", "The values kept on the device could not be read and were removed, starting without them");
1428
+ await this.removeEntry();
1429
+ return { outcome: "unreadable" };
1430
+ }
1431
+ return {
1432
+ outcome: "stored",
1433
+ cachedValues: entry
1434
+ };
1435
+ }
1436
+ parseEntry(raw) {
1437
+ try {
1438
+ const candidate = JSON.parse(raw);
1439
+ return isEntryFor(this.clientSdkKey, candidate) ? candidate : void 0;
1440
+ } catch {
1441
+ return;
1442
+ }
1443
+ }
1444
+ async writeQueued() {
1445
+ if (this.writing) return;
1446
+ this.writing = true;
1447
+ try {
1448
+ while (this.queuedCachedValues) {
1449
+ const cachedValues = this.queuedCachedValues;
1450
+ this.queuedCachedValues = void 0;
1451
+ await this.writeEntry(cachedValues);
1452
+ }
1453
+ } finally {
1454
+ this.writing = false;
1455
+ }
1456
+ }
1457
+ async writeEntry(cachedValues) {
1458
+ const entry = {
1459
+ formatVersion: ENTRY_FORMAT_VERSION,
1460
+ clientSdkKey: this.clientSdkKey,
1461
+ ...cachedValues
1462
+ };
1463
+ const serialized = JSON.stringify(entry);
1464
+ const size = utf8ByteLength(serialized);
1465
+ const limit = this.provider.sizeLimit();
1466
+ if (size > limit) {
1467
+ this.warnOnce("over the size limit", `The values received (${size} bytes) exceed the caching provider's limit of ${limit} bytes, so no values are kept on the device`);
1468
+ await this.removeEntry();
1469
+ return;
1470
+ }
1471
+ try {
1472
+ await this.provider.write(serialized);
1473
+ } catch (error) {
1474
+ this.warnOnce("write failed", "Could not keep the values on the device:", error);
1475
+ }
1476
+ }
1477
+ async removeEntry() {
1478
+ try {
1479
+ await this.provider.remove();
1480
+ } catch (error) {
1481
+ this.warnOnce("remove failed", "Could not remove the values kept on the device:", error);
1482
+ }
1483
+ }
1484
+ warnOnce(warning, message, ...details) {
1485
+ if (this.warned.has(warning)) return;
1486
+ this.warned.add(warning);
1487
+ this.logger.warn(`[ConfigDirectorClient] ${message}`, ...details);
1488
+ }
1489
+ };
1490
+ //#endregion
1322
1491
  //#region ../js-client-core/src/DefaultConfigDirectorClient.ts
1323
1492
  const MAX_EXPONENTIAL_DELAY = 9;
1324
1493
  var DefaultConfigDirectorClient = class DefaultConfigDirectorClient {
1325
- static from(liveTelemetryClient, clientSdkKeyOrOptions, sdkOptions, clientOptions) {
1326
- if (typeof clientSdkKeyOrOptions === "object") return new DefaultConfigDirectorClient(liveTelemetryClient, void 0, sdkOptions, clientSdkKeyOrOptions);
1327
- return new DefaultConfigDirectorClient(liveTelemetryClient, clientSdkKeyOrOptions, sdkOptions, clientOptions);
1494
+ static from(liveTelemetryClient, clientSdkKeyOrOptions, sdkOptions, clientOptions, internalClientOptions) {
1495
+ if (typeof clientSdkKeyOrOptions === "object") return new DefaultConfigDirectorClient(liveTelemetryClient, void 0, sdkOptions, clientSdkKeyOrOptions, internalClientOptions);
1496
+ return new DefaultConfigDirectorClient(liveTelemetryClient, clientSdkKeyOrOptions, sdkOptions, clientOptions, internalClientOptions);
1328
1497
  }
1329
1498
  constructor(liveTelemetryClient, clientSdkKey, sdkOptions, clientOptions, internalClientOptions) {
1330
- var _clientOptions$logger, _clientOptions$connec, _clientOptions$connec2, _internalClientOption, _internalClientOption2, _clientOptions$hooks, _clientOptions$hooks2, _clientOptions$hooks3, _clientOptions$hooks4, _clientOptions$hooks5;
1499
+ var _clientOptions$logger, _clientOptions$connec, _clientOptions$connec2, _internalClientOption, _internalClientOption2, _clientOptions$hooks, _clientOptions$hooks2, _clientOptions$hooks3, _clientOptions$hooks4, _clientOptions$hooks5, _clientOptions$hooks6;
1331
1500
  _defineProperty(this, "logger", void 0);
1332
1501
  _defineProperty(this, "telemetryClient", void 0);
1333
1502
  _defineProperty(this, "configSet", void 0);
@@ -1342,18 +1511,24 @@ var DefaultConfigDirectorClient = class DefaultConfigDirectorClient {
1342
1511
  _defineProperty(this, "readyPromise", void 0);
1343
1512
  _defineProperty(this, "readyResolve", void 0);
1344
1513
  _defineProperty(this, "currentContext", void 0);
1514
+ _defineProperty(this, "submittedContext", void 0);
1345
1515
  _defineProperty(this, "connectionPlan", void 0);
1346
1516
  _defineProperty(this, "clientSession", void 0);
1517
+ _defineProperty(this, "cachedValuesStore", void 0);
1518
+ _defineProperty(this, "servingCachedValues", false);
1519
+ _defineProperty(this, "cachedValuesAdoptionStarted", false);
1347
1520
  this.logger = (_clientOptions$logger = clientOptions === null || clientOptions === void 0 ? void 0 : clientOptions.logger) !== null && _clientOptions$logger !== void 0 ? _clientOptions$logger : createDefaultLogger();
1348
1521
  this.clientSession = resolveClientSession(this.logger, internalClientOptions === null || internalClientOptions === void 0 ? void 0 : internalClientOptions.instanceIdStorage);
1349
1522
  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;
1350
1523
  const urlFactory = (_internalClientOption = internalClientOptions === null || internalClientOptions === void 0 ? void 0 : internalClientOptions.urlFactory) !== null && _internalClientOption !== void 0 ? _internalClientOption : defaultUrlFactory;
1351
1524
  this.connectionPlan = planConnection(clientSdkKey, clientOptions === null || clientOptions === void 0 ? void 0 : clientOptions.connection, urlFactory);
1525
+ this.cachedValuesStore = this.buildCachedValuesStore(planCaching(this.connectionPlan, clientOptions === null || clientOptions === void 0 ? void 0 : clientOptions.caching, internalClientOptions === null || internalClientOptions === void 0 ? void 0 : internalClientOptions.platformCachingProvider));
1352
1526
  this.telemetryClient = this.buildTelemetryClient(liveTelemetryClient, clientOptions);
1353
1527
  this.transport = (_internalClientOption2 = internalClientOptions === null || internalClientOptions === void 0 ? void 0 : internalClientOptions.transport) !== null && _internalClientOption2 !== void 0 ? _internalClientOption2 : this.buildTransport(sdkOptions, clientOptions, urlFactory, internalClientOptions);
1354
1528
  this.transport.on("configSetReceived", (configSet) => {
1355
1529
  var _this$readyResolve;
1356
1530
  this.hasReceivedConfigSet = true;
1531
+ this.servingCachedValues = false;
1357
1532
  (_this$readyResolve = this.readyResolve) === null || _this$readyResolve === void 0 || _this$readyResolve.call(this);
1358
1533
  const removedKeys = this.findKeysRemovedBy(configSet);
1359
1534
  if (!this.configSet || configSet.kind == "full") this.configSet = configSet;
@@ -1361,6 +1536,7 @@ var DefaultConfigDirectorClient = class DefaultConfigDirectorClient {
1361
1536
  ...this.configSet.configs,
1362
1537
  ...configSet.configs
1363
1538
  };
1539
+ this.keepValuesOnTheDevice(this.configSet);
1364
1540
  const keys = Object.keys(configSet.configs);
1365
1541
  this.emit("configsUpdated", {
1366
1542
  keys,
@@ -1382,6 +1558,80 @@ var DefaultConfigDirectorClient = class DefaultConfigDirectorClient {
1382
1558
  this.registerHandler("configsUpdated", clientOptions === null || clientOptions === void 0 || (_clientOptions$hooks3 = clientOptions.hooks) === null || _clientOptions$hooks3 === void 0 ? void 0 : _clientOptions$hooks3.configsUpdated);
1383
1559
  this.registerHandler("contextUpdated", clientOptions === null || clientOptions === void 0 || (_clientOptions$hooks4 = clientOptions.hooks) === null || _clientOptions$hooks4 === void 0 ? void 0 : _clientOptions$hooks4.contextUpdated);
1384
1560
  this.registerHandler("connectionError", clientOptions === null || clientOptions === void 0 || (_clientOptions$hooks5 = clientOptions.hooks) === null || _clientOptions$hooks5 === void 0 ? void 0 : _clientOptions$hooks5.connectionError);
1561
+ this.registerHandler("cachedValuesLoaded", clientOptions === null || clientOptions === void 0 || (_clientOptions$hooks6 = clientOptions.hooks) === null || _clientOptions$hooks6 === void 0 ? void 0 : _clientOptions$hooks6.cachedValuesLoaded);
1562
+ }
1563
+ buildCachedValuesStore(cachingPlan) {
1564
+ switch (cachingPlan.kind) {
1565
+ case "no cache":
1566
+ this.logger.debug(`[ConfigDirectorClient] No values are kept on the device: ${cachingPlan.reason}`);
1567
+ return;
1568
+ case "cache": {
1569
+ const store = new CachedValuesStore(cachingPlan.provider, cachingPlan.clientSdkKey, this.logger);
1570
+ store.read();
1571
+ return store;
1572
+ }
1573
+ }
1574
+ }
1575
+ startCachedValuesAdoption() {
1576
+ const store = this.cachedValuesStore;
1577
+ if (!store || this.cachedValuesAdoptionStarted) return;
1578
+ this.cachedValuesAdoptionStarted = true;
1579
+ this.adoptCachedValues(store);
1580
+ }
1581
+ async adoptCachedValues(store) {
1582
+ const read = await store.read();
1583
+ switch (read.outcome) {
1584
+ case "nothing stored":
1585
+ case "unreadable":
1586
+ case "failed": return;
1587
+ case "stored": break;
1588
+ }
1589
+ const { instanceId } = await this.clientSession;
1590
+ const launchContextTag = contextTagFor(this.submittedContext, instanceId);
1591
+ const adoption = decideCachedValuesAdoption(read.cachedValues, launchContextTag, this.hasReceivedConfigSet);
1592
+ switch (adoption) {
1593
+ case "adopt":
1594
+ this.serveCachedValues(read.cachedValues);
1595
+ return;
1596
+ case "live values already arrived":
1597
+ case "context mismatch":
1598
+ this.logger.debug(`[ConfigDirectorClient] The values kept on the device were not adopted: ${adoption}`);
1599
+ return;
1600
+ }
1601
+ }
1602
+ serveCachedValues(cachedValues) {
1603
+ const { environmentId, projectId, configs } = cachedValues;
1604
+ this.configSet = {
1605
+ environmentId,
1606
+ projectId,
1607
+ configs,
1608
+ kind: "full"
1609
+ };
1610
+ this.servingCachedValues = true;
1611
+ const keys = Object.keys(configs);
1612
+ this.emit("cachedValuesLoaded", {});
1613
+ this.emit("configsUpdated", {
1614
+ keys,
1615
+ removedKeys: []
1616
+ });
1617
+ this.updateWatchers(keys);
1618
+ this.logger.debug("[ConfigDirectorClient] Serving the values kept on the device until live values arrive:", { keys });
1619
+ }
1620
+ keepValuesOnTheDevice(configSet) {
1621
+ const store = this.cachedValuesStore;
1622
+ if (!store) return;
1623
+ const context = this.submittedContext;
1624
+ const receivedAt = Date.now();
1625
+ this.clientSession.then(({ instanceId }) => store.replace({
1626
+ contextTag: contextTagFor(context, instanceId),
1627
+ environmentId: configSet.environmentId,
1628
+ projectId: configSet.projectId,
1629
+ configs: configSet.configs,
1630
+ receivedAt
1631
+ }));
1632
+ }
1633
+ servedReason(reason) {
1634
+ return this.servingCachedValues && reason === "found-match" ? "cached" : reason;
1385
1635
  }
1386
1636
  registerHandler(event, handler) {
1387
1637
  if (!handler) return;
@@ -1477,6 +1727,8 @@ var DefaultConfigDirectorClient = class DefaultConfigDirectorClient {
1477
1727
  return this.currentContext;
1478
1728
  }
1479
1729
  async connectToTransport(context, caller) {
1730
+ this.submittedContext = context;
1731
+ if (caller === "initialization") this.startCachedValuesAdoption();
1480
1732
  try {
1481
1733
  this.ready = false;
1482
1734
  this.readyPromise = new Promise((resolve) => {
@@ -1581,6 +1833,7 @@ var DefaultConfigDirectorClient = class DefaultConfigDirectorClient {
1581
1833
  return defaultValue;
1582
1834
  }
1583
1835
  const parseResult = parseConfigValue(configState, defaultValue);
1836
+ const reason = this.servedReason(parseResult.reason);
1584
1837
  this.telemetryClient.evaluatedConfig({
1585
1838
  contextId: (_this$currentContext2 = this.currentContext) === null || _this$currentContext2 === void 0 ? void 0 : _this$currentContext2.id,
1586
1839
  type: configState.type,
@@ -1590,14 +1843,14 @@ var DefaultConfigDirectorClient = class DefaultConfigDirectorClient {
1590
1843
  evaluatedValue: parseResult.parsedValue,
1591
1844
  evaluatedValueId: parseResult.parsedValueId,
1592
1845
  usedDefault: parseResult.usedDefault,
1593
- evaluationReason: parseResult.reason
1846
+ evaluationReason: reason
1594
1847
  });
1595
1848
  this.dispatchEvaluationEvent({
1596
1849
  key: configKey,
1597
1850
  value: parseResult.parsedValue,
1598
1851
  valueId: (_parseResult$parsedVa = parseResult.parsedValueId) !== null && _parseResult$parsedVa !== void 0 ? _parseResult$parsedVa : void 0,
1599
1852
  isDefaultValue: parseResult.usedDefault,
1600
- reason: parseResult.reason,
1853
+ reason,
1601
1854
  context: this.context
1602
1855
  });
1603
1856
  this.logger.debug(`[ConfigDirectorClient] Evaluated '${configKey}' to '${parseResult.parsedValue}'`);
@@ -1677,4 +1930,4 @@ var DefaultConfigDirectorClient = class DefaultConfigDirectorClient {
1677
1930
  const INSTALLED_TEST_CLIENT_SLOT = Symbol.for("@configdirector/installed-test-client");
1678
1931
  const readInstalledTestClient = () => globalThis[INSTALLED_TEST_CLIENT_SLOT];
1679
1932
  //#endregion
1680
- export { discardingTelemetryClient as a, isFetchErrorFatal as c, Emitter as d, _defineProperty as f, fetchWithTimeout as i, ConfigDirectorConnectionError as l, readInstalledTestClient as n, createDefaultLogger as o, DefaultConfigDirectorClient as r, nativeValueConfigState as s, INSTALLED_TEST_CLIENT_SLOT as t, ConfigDirectorValidationError as u };
1933
+ export { DEFAULT_CACHING_SIZE_LIMIT_BYTES as a, createDefaultLogger as c, ConfigDirectorConnectionError as d, ConfigDirectorValidationError as f, CACHED_VALUES_STORAGE_KEY_PREFIX as i, nativeValueConfigState as l, _defineProperty as m, readInstalledTestClient as n, fetchWithTimeout as o, Emitter as p, DefaultConfigDirectorClient as r, discardingTelemetryClient as s, INSTALLED_TEST_CLIENT_SLOT as t, isFetchErrorFatal as u };
@@ -23,7 +23,7 @@ interface ConfigDirectorLogger {
23
23
  interface ConfigDirectorLogMessageDecorator {
24
24
  decorateMessage(message: string): string;
25
25
  }
26
- type EvaluationReason = "found-match" | "config-state-missing" | "client-not-ready" | "type-mismatch" | "value-missing" | "invalid-number" | "invalid-json" | "invalid-boolean";
26
+ type EvaluationReason = "found-match" | "config-state-missing" | "client-not-ready" | "type-mismatch" | "value-missing" | "invalid-number" | "invalid-json" | "invalid-boolean" | "cached";
27
27
  type ConfigEvaluation = {
28
28
  key: string;
29
29
  value: ConfigValueType;
@@ -42,9 +42,11 @@ type ConfigDirectorContext = {
42
42
  * The context's identifier. This should be a value that uniquely identifies the user or other
43
43
  * entity among contexts of the same {@link kind}.
44
44
  * In the case of anonymous users, you could generate a UUID or alternatively not provide
45
- * the {@link id} and the SDK will generate a random UUID. However, keep in mind that this
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.
45
+ * the {@link id}. In a client SDK, ConfigDirector then derives an identifier from an ID the SDK
46
+ * stores for the app install or the website, so it stays the same until the app is uninstalled
47
+ * or the site's data is cleared. However, keep in mind that this value is used for segmenting
48
+ * contexts in percentage rollouts, and changes to the {@link id} could result in the context
49
+ * being assigned to a different percentile.
48
50
  */
49
51
  id?: string;
50
52
  /**
@@ -100,6 +102,45 @@ type NativeValue = boolean | number | string | NativeJsonValue[] | {
100
102
  type NativeValues = Record<string, NativeValue>;
101
103
  //#endregion
102
104
  //#region ../js-client-core/src/types.d.ts
105
+ /**
106
+ * A store for the values the client keeps on the device between launches. One provider instance
107
+ * serves one client: the provider decides where the entry lives, and the SDK owns the entry's
108
+ * format, which may change between SDK versions.
109
+ */
110
+ type CachingProvider = {
111
+ /**
112
+ * Returns the stored entry, or `null` or `undefined` when nothing is stored.
113
+ */
114
+ read(): string | null | undefined | Promise<string | null | undefined>;
115
+ /**
116
+ * Replaces the stored entry. The SDK never writes an entry larger than {@link sizeLimit}.
117
+ */
118
+ write(value: string): void | Promise<void>;
119
+ /**
120
+ * Removes the stored entry.
121
+ */
122
+ remove(): void | Promise<void>;
123
+ /**
124
+ * The largest entry, in UTF-8 bytes, the provider accepts. When the values received from
125
+ * ConfigDirector exceed it, the SDK removes the stored entry and keeps nothing on the device.
126
+ */
127
+ sizeLimit(): number;
128
+ };
129
+ /**
130
+ * Options for the values the client keeps on the device, served on the next launch of the same
131
+ * context until live values arrive. Ignored in `offline` mode.
132
+ */
133
+ type CachingOptions = {
134
+ /**
135
+ * Whether to keep the last values received on the device. Defaults to `true`.
136
+ */
137
+ enabled?: boolean;
138
+ /**
139
+ * The store for the values. Defaults to the platform's own store: `localStorage` in the
140
+ * browser, AsyncStorage on React Native.
141
+ */
142
+ provider?: CachingProvider;
143
+ };
103
144
  /**
104
145
  * One value an offline client serves for a config key. The config type follows from the value: a
105
146
  * boolean, an integral number (`integer`), any other number (`float`), a string, or a plain object
@@ -170,6 +211,12 @@ type ConfigDirectorClientOptions = {
170
211
  * Connection options
171
212
  */
172
213
  connection?: ConnectionOptions;
214
+ /**
215
+ * Options for the values the client keeps on the device between launches. The last values
216
+ * received are kept by default and served, with the evaluation reason `cached`, on the next
217
+ * launch of the same context until live values arrive. See {@link CachingOptions}.
218
+ */
219
+ caching?: CachingOptions;
173
220
  /**
174
221
  * A logger that implements {@link ConfigDirectorLogger}. It defaults to the ConfigDirector console
175
222
  * logger set to 'warn' level.
@@ -207,6 +254,7 @@ type ClientHooks = {
207
254
  contextUpdated?: HookHandler<"contextUpdated"> | HookHandler<"contextUpdated">[];
208
255
  configEvaluated?: HookHandler<"configEvaluated"> | HookHandler<"configEvaluated">[];
209
256
  connectionError?: HookHandler<"connectionError"> | HookHandler<"connectionError">[];
257
+ cachedValuesLoaded?: HookHandler<"cachedValuesLoaded"> | HookHandler<"cachedValuesLoaded">[];
210
258
  };
211
259
  type ClientConnectAction = "initialization" | "context update" | "network resume";
212
260
  type HookHandler<TEvent extends keyof ClientEvents> = (payload: ClientEvents[TEvent]) => void;
@@ -227,6 +275,7 @@ type ClientEvents = {
227
275
  connectionError: {
228
276
  error: Error;
229
277
  };
278
+ cachedValuesLoaded: Record<string, never>;
230
279
  };
231
280
  type WatchHandler<T extends ConfigValueType> = (message: T) => void;
232
281
  /**
@@ -348,4 +397,4 @@ interface ConfigDirectorClient extends EventProvider<ClientEvents> {
348
397
  dispose(): void;
349
398
  }
350
399
  //#endregion
351
- export { OfflineValues as a, NativeValues as c, ConfigDirectorLogger as d, ConfigDirectorLoggingLevel as f, OfflineValue as i, ConfigDirectorContext as l, ConnectionMode as m, ConfigDirectorClient as n, NativeJsonValue as o, ConfigValueType as p, ConfigDirectorClientOptions as r, NativeValue as s, ClientHooks as t, ConfigDirectorLogMessageDecorator as u };
400
+ export { ConfigDirectorClientOptions as a, NativeJsonValue as c, ConfigDirectorContext as d, ConfigDirectorLogMessageDecorator as f, ConnectionMode as g, ConfigValueType as h, ConfigDirectorClient as i, NativeValue as l, ConfigDirectorLoggingLevel as m, CachingProvider as n, OfflineValue as o, ConfigDirectorLogger as p, ClientHooks as r, OfflineValues as s, CachingOptions as t, NativeValues as u };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@configdirector/react-native-sdk",
3
- "version": "1.8.0",
3
+ "version": "1.9.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",