@layers/amba-react-native 4.1.0 → 4.2.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/dist/index.cjs CHANGED
@@ -1818,6 +1818,296 @@ function logListenerError(err) {
1818
1818
  console.warn("[amba] onExperimentAssigned listener threw", err);
1819
1819
  }
1820
1820
 
1821
+ //#endregion
1822
+ //#region src/remote-values.ts
1823
+ /** Prefix of the per-project AsyncStorage record key. */
1824
+ const CACHE_KEY_PREFIX = "amba_rc_v1_";
1825
+ /** Slot for values evaluated without a signed-in user. */
1826
+ const ANON_SLOT = "anon";
1827
+ /** Most user slots one project record keeps. */
1828
+ const MAX_SLOTS = 5;
1829
+ const TWO_32 = 4294967296;
1830
+ /**
1831
+ * 64-bit FNV-1a over the string's UTF-16 code units (low byte, then high
1832
+ * byte), as 16 hex digits. Computed in two 32-bit halves: some React Native
1833
+ * engines (JavaScriptCore on older Android builds) have no BigInt.
1834
+ */
1835
+ function fnv1a64Hex(text) {
1836
+ let hi = 3421674724;
1837
+ let lo = 2216829733;
1838
+ const step = (byte) => {
1839
+ lo = (lo ^ byte) >>> 0;
1840
+ const low = lo * 435;
1841
+ const carry = Math.floor(low / TWO_32);
1842
+ const high = hi * 435 + carry + (lo << 8 >>> 0);
1843
+ lo = low % TWO_32;
1844
+ hi = high % TWO_32;
1845
+ };
1846
+ for (let i = 0; i < text.length; i++) {
1847
+ const unit = text.charCodeAt(i);
1848
+ step(unit & 255);
1849
+ step(unit >>> 8);
1850
+ }
1851
+ return hi.toString(16).padStart(8, "0") + lo.toString(16).padStart(8, "0");
1852
+ }
1853
+ /** The record key for a project: a hash of the base URL and client key. */
1854
+ function recordKey(baseUrl, clientKey) {
1855
+ return `${CACHE_KEY_PREFIX}${fnv1a64Hex(`${baseUrl.replace(/\/+$/, "")}\n${clientKey}`)}`;
1856
+ }
1857
+ /** The slot key for an identity: `u` + a hash of the user id, or `anon`. */
1858
+ function slotKey(appUserId) {
1859
+ return appUserId ? `u${fnv1a64Hex(appUserId)}` : ANON_SLOT;
1860
+ }
1861
+ /**
1862
+ * The user a config response was evaluated for, from its `ETag`:
1863
+ * `"<version>:<app user id>:<segments>"` for a signed-in caller, `"<version>"`
1864
+ * for every other one (no session, or an expired or revoked token, which the
1865
+ * server answers with each key's default value). `undefined` without an
1866
+ * `ETag`; `null` for an evaluation for nobody.
1867
+ */
1868
+ function etagEvaluatedFor(etag) {
1869
+ if (!etag) return void 0;
1870
+ const parts = etag.trim().replace(/^W\//, "").replace(/^"|"$/g, "").split(":");
1871
+ return parts.length > 1 && parts[1] ? parts[1] : null;
1872
+ }
1873
+ function isPlainObject(value) {
1874
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1875
+ }
1876
+ function validSlot(raw) {
1877
+ if (!isPlainObject(raw)) return null;
1878
+ const slot = { saved_at: typeof raw["saved_at"] === "number" ? raw["saved_at"] : 0 };
1879
+ const config = raw["config"];
1880
+ if (isPlainObject(config) && isPlainObject(config["values"])) slot.config = {
1881
+ version: typeof config["version"] === "string" ? config["version"] : null,
1882
+ values: config["values"]
1883
+ };
1884
+ const flags = raw["flags"];
1885
+ if (Array.isArray(flags)) slot.flags = flags.filter((f) => isPlainObject(f) && typeof f["name"] === "string" && typeof f["enabled"] === "boolean");
1886
+ return slot.config || slot.flags ? slot : null;
1887
+ }
1888
+ function upsert(list, flag) {
1889
+ const i = list.findIndex((f) => f.name === flag.name);
1890
+ if (i >= 0) list[i] = flag;
1891
+ else list.push(flag);
1892
+ }
1893
+ var RemoteValuesCache = class {
1894
+ record = {
1895
+ v: 1,
1896
+ slots: {}
1897
+ };
1898
+ /** The slot key the held values were resolved for. */
1899
+ viewFor = null;
1900
+ config = null;
1901
+ flags = null;
1902
+ /** Bumped by every sign-out; an older ticket's response is not cached. */
1903
+ generation = 0;
1904
+ writes = Promise.resolve();
1905
+ constructor(store, key, defaults, currentUser) {
1906
+ this.store = store;
1907
+ this.key = key;
1908
+ this.defaults = defaults;
1909
+ this.currentUser = currentUser;
1910
+ }
1911
+ /** Read the project's record from storage. `configure()` awaits it. */
1912
+ async load() {
1913
+ let raw = null;
1914
+ try {
1915
+ raw = await this.store.read(this.key);
1916
+ } catch {
1917
+ return;
1918
+ }
1919
+ if (!raw) return;
1920
+ try {
1921
+ const parsed = JSON.parse(raw);
1922
+ const slots = {};
1923
+ if (isPlainObject(parsed) && isPlainObject(parsed["slots"])) for (const [k, v] of Object.entries(parsed["slots"])) {
1924
+ const slot = validSlot(v);
1925
+ if (slot) slots[k] = slot;
1926
+ }
1927
+ this.record = {
1928
+ v: 1,
1929
+ slots
1930
+ };
1931
+ } catch {
1932
+ this.record = {
1933
+ v: 1,
1934
+ slots: {}
1935
+ };
1936
+ this.persist();
1937
+ }
1938
+ }
1939
+ /** Take a ticket for a request about to be sent for the current user. */
1940
+ ticket() {
1941
+ return {
1942
+ generation: this.generation,
1943
+ requestedFor: this.currentUser()
1944
+ };
1945
+ }
1946
+ /** The value for config `key` (fetched, cached, then default); `undefined` when none. */
1947
+ configValue(key) {
1948
+ this.ensureView();
1949
+ const values = this.config?.values;
1950
+ if (values && Object.prototype.hasOwnProperty.call(values, key)) {
1951
+ const v = values[key];
1952
+ if (v !== null && v !== void 0) return v;
1953
+ }
1954
+ return this.defaultFor(key);
1955
+ }
1956
+ /** The held assignment for flag `key`, or `null`. */
1957
+ flag(key) {
1958
+ this.ensureView();
1959
+ return this.flags?.find((f) => f.name === key) ?? null;
1960
+ }
1961
+ /** Whether flag `key` is on: the held assignment, then a boolean default. */
1962
+ flagEnabled(key) {
1963
+ const flag = this.flag(key);
1964
+ if (flag) return flag.enabled;
1965
+ const d = this.defaultFor(key);
1966
+ return typeof d === "boolean" ? d : void 0;
1967
+ }
1968
+ /**
1969
+ * Hold and persist a fetched bundle; returns it. A bundle the server
1970
+ * evaluated for nobody although the request carried a user (an expired or
1971
+ * revoked session) goes to the `anon` slot and leaves the user's values
1972
+ * alone; one evaluated for another user is not cached.
1973
+ */
1974
+ storeConfig(ticket, bundle, etag) {
1975
+ if (!isPlainObject(bundle.values)) return bundle;
1976
+ const evaluatedFor = etagEvaluatedFor(etag);
1977
+ let target;
1978
+ if (ticket.requestedFor && evaluatedFor === null) target = ANON_SLOT;
1979
+ else if (typeof evaluatedFor === "string" && evaluatedFor !== ticket.requestedFor) return bundle;
1980
+ this.write(ticket, target, (slot, held) => {
1981
+ slot.config = bundle;
1982
+ if (held) this.config = bundle;
1983
+ });
1984
+ return bundle;
1985
+ }
1986
+ /** Hold and persist a fetched flag list (it replaces the held one). */
1987
+ storeFlags(ticket, flags) {
1988
+ this.write(ticket, void 0, (slot, held) => {
1989
+ slot.flags = [...flags];
1990
+ if (held) this.flags = [...flags];
1991
+ });
1992
+ return flags;
1993
+ }
1994
+ /** Hold and persist one flag from a single-flag lookup; returns it. */
1995
+ upsertFlag(ticket, flag) {
1996
+ this.write(ticket, void 0, (slot, held) => {
1997
+ this.adoptHeldFlags(slot, held);
1998
+ slot.flags = slot.flags ?? [];
1999
+ upsert(slot.flags, flag);
2000
+ if (held) {
2001
+ this.flags = this.flags ?? [];
2002
+ upsert(this.flags, flag);
2003
+ }
2004
+ });
2005
+ return flag;
2006
+ }
2007
+ /** Drop flag `key` after a single-flag lookup found it off or unknown. */
2008
+ removeFlag(ticket, key) {
2009
+ this.write(ticket, void 0, (slot, held) => {
2010
+ this.adoptHeldFlags(slot, held);
2011
+ if (slot.flags) slot.flags = slot.flags.filter((f) => f.name !== key);
2012
+ if (held && this.flags) this.flags = this.flags.filter((f) => f.name !== key);
2013
+ });
2014
+ }
2015
+ /**
2016
+ * Sign-out: delete the signed-out user's slot and drop the held values;
2017
+ * later lookups serve the `anon` slot, then the defaults.
2018
+ */
2019
+ clearForSignOut(signedOutUser) {
2020
+ this.generation += 1;
2021
+ this.config = null;
2022
+ this.flags = null;
2023
+ this.viewFor = null;
2024
+ if (!signedOutUser) return;
2025
+ const doomed = slotKey(signedOutUser);
2026
+ if (this.record.slots[doomed]) {
2027
+ delete this.record.slots[doomed];
2028
+ this.persist();
2029
+ }
2030
+ }
2031
+ /** Resolves when every write so far reached storage. */
2032
+ flushed() {
2033
+ return this.writes;
2034
+ }
2035
+ /**
2036
+ * Before a single-flag change to the current user's slot: when the slot
2037
+ * holds no flag list and the served list is the signed-out one, the
2038
+ * user's slot starts from that list, so after a relaunch the user reads
2039
+ * the same flags the change was made on.
2040
+ */
2041
+ adoptHeldFlags(slot, held) {
2042
+ if (held && !slot.flags && this.flags) slot.flags = [...this.flags];
2043
+ }
2044
+ defaultFor(key) {
2045
+ if (!Object.prototype.hasOwnProperty.call(this.defaults, key)) return void 0;
2046
+ const v = this.defaults[key];
2047
+ return v === null ? void 0 : v;
2048
+ }
2049
+ /**
2050
+ * `held` is `true` when the slot written is the one the current identity
2051
+ * reads, so the served values change with it.
2052
+ */
2053
+ write(ticket, targetOverride, apply) {
2054
+ const current = slotKey(this.currentUser());
2055
+ if (ticket.generation !== this.generation || slotKey(ticket.requestedFor) !== current) return;
2056
+ this.ensureView();
2057
+ const target = targetOverride ?? current;
2058
+ const existing = this.record.slots[target];
2059
+ const slot = existing ? {
2060
+ saved_at: existing.saved_at,
2061
+ ...existing.config ? { config: existing.config } : {},
2062
+ ...existing.flags ? { flags: [...existing.flags] } : {}
2063
+ } : { saved_at: 0 };
2064
+ apply(slot, target === current);
2065
+ if (!slot.config && !slot.flags) {
2066
+ if (!existing) return;
2067
+ delete this.record.slots[target];
2068
+ } else {
2069
+ slot.saved_at = Date.now();
2070
+ this.record.slots[target] = slot;
2071
+ this.prune(target);
2072
+ }
2073
+ this.persist();
2074
+ if (target !== current) this.viewFor = null;
2075
+ }
2076
+ /** Serve the current identity's own slot, the `anon` slot for the rest. */
2077
+ ensureView() {
2078
+ const current = slotKey(this.currentUser());
2079
+ if (this.viewFor === current) return;
2080
+ const own = this.record.slots[current];
2081
+ const anon = current === ANON_SLOT ? void 0 : this.record.slots[ANON_SLOT];
2082
+ const config = own?.config ?? anon?.config;
2083
+ const flags = own?.flags ?? anon?.flags;
2084
+ this.config = config ?? null;
2085
+ this.flags = flags ? [...flags] : null;
2086
+ this.viewFor = current;
2087
+ }
2088
+ /**
2089
+ * Keep the MAX_SLOTS most recently written user slots; `keep` and the
2090
+ * signed-out slot (read before sign-in and for every user's gaps) stay.
2091
+ */
2092
+ prune(keep) {
2093
+ const keys = Object.keys(this.record.slots).filter((k) => k !== ANON_SLOT);
2094
+ while (keys.length > MAX_SLOTS) {
2095
+ let oldest = null;
2096
+ for (const k of keys) {
2097
+ if (k === keep) continue;
2098
+ if (oldest === null || this.record.slots[k].saved_at < this.record.slots[oldest].saved_at) oldest = k;
2099
+ }
2100
+ if (oldest === null) break;
2101
+ delete this.record.slots[oldest];
2102
+ keys.splice(keys.indexOf(oldest), 1);
2103
+ }
2104
+ }
2105
+ persist() {
2106
+ const text = Object.keys(this.record.slots).length === 0 ? null : JSON.stringify(this.record);
2107
+ this.writes = this.writes.then(() => text === null ? this.store.delete(this.key) : this.store.write(this.key, text)).catch(() => {});
2108
+ }
2109
+ };
2110
+
1821
2111
  //#endregion
1822
2112
  //#region src/client-info.ts
1823
2113
  /**
@@ -2200,7 +2490,7 @@ function delay(ms) {
2200
2490
  //#endregion
2201
2491
  //#region src/index.ts
2202
2492
  /** SDK semver — burned in at build time. Bump alongside package.json. */
2203
- const SDK_VERSION = "4.1.0";
2493
+ const SDK_VERSION = "4.2.0";
2204
2494
  /**
2205
2495
  * Default host that serves deployed functions. `Amba.functions.invoke(name)`
2206
2496
  * targets `https://<projectId>.<DEFAULT_FUNCTIONS_HOST>/<name>` unless a
@@ -2244,6 +2534,62 @@ const authSubscribers = /* @__PURE__ */ new Set();
2244
2534
  * start a new session.
2245
2535
  */
2246
2536
  const experimentReporter = new ExperimentAssignmentReporter();
2537
+ /**
2538
+ * `configDefaults` from the latest `configure()` call, recorded before
2539
+ * configure reads the AsyncStorage cache. `config.getCached` and
2540
+ * `flags.isEnabled` serve them on a first render that runs while
2541
+ * `configure()` is still in flight.
2542
+ */
2543
+ let _pendingConfigDefaults = null;
2544
+ /** Validate `AmbaConfig.configDefaults`: absent, or a plain object. */
2545
+ function readConfigDefaults(value) {
2546
+ if (value === void 0 || value === null) return null;
2547
+ if (typeof value !== "object" || Array.isArray(value)) throw new AmbaApiError("INVALID_CONFIG", "Amba.configure: configDefaults must be an object of config key to value");
2548
+ return value;
2549
+ }
2550
+ /** The pending `configDefaults` entry for `key`, ignoring inherited properties. */
2551
+ function pendingDefault(key) {
2552
+ const defaults = _pendingConfigDefaults;
2553
+ if (!defaults || !Object.prototype.hasOwnProperty.call(defaults, key)) return;
2554
+ const value = defaults[key];
2555
+ return value === null ? void 0 : value;
2556
+ }
2557
+ /** `"array"` for arrays, else `typeof` — the JSON kind of a value. */
2558
+ function jsonKind(value) {
2559
+ return Array.isArray(value) ? "array" : typeof value;
2560
+ }
2561
+ function configGetCached(key, defaultValue) {
2562
+ if (state) refreshOnFirstRead(state, "config");
2563
+ const raw = state ? state.remoteValues.configValue(key) : pendingDefault(key);
2564
+ if (raw === void 0 || raw === null) return defaultValue;
2565
+ if (defaultValue !== void 0 && defaultValue !== null && jsonKind(raw) !== jsonKind(defaultValue)) return defaultValue;
2566
+ return raw;
2567
+ }
2568
+ /** `Amba.flags.isEnabled`. */
2569
+ function flagsIsEnabled(key, defaultValue = false) {
2570
+ if (state) refreshOnFirstRead(state, "flags");
2571
+ const held = state ? state.remoteValues.flagEnabled(key) : pendingDefault(key);
2572
+ return typeof held === "boolean" ? held : defaultValue;
2573
+ }
2574
+ /** `Amba.flags.getCached`. */
2575
+ function flagsGetCached(key) {
2576
+ if (!state) return null;
2577
+ refreshOnFirstRead(state, "flags");
2578
+ return state.remoteValues.flag(key);
2579
+ }
2580
+ /**
2581
+ * The first on-device read of config (or flags) for each identity after
2582
+ * `configure()` starts one background fetch that replaces the cached values:
2583
+ * once at launch, and once more after a sign-in or sign-out changes who is
2584
+ * reading. Apps that never read cached values make no extra request. A
2585
+ * failure (offline) leaves the cached values in place.
2586
+ */
2587
+ function refreshOnFirstRead(s, kind) {
2588
+ const identity = s.identity.getAppUserId() ?? "";
2589
+ if (s.refreshedFor[kind] === identity) return;
2590
+ s.refreshedFor[kind] = identity;
2591
+ Promise.resolve().then(() => kind === "config" ? Amba.config.fetch() : Amba.flags.fetch()).catch(() => {});
2592
+ }
2247
2593
  function snapshotSession(user) {
2248
2594
  if (!user) return null;
2249
2595
  return {
@@ -2866,6 +3212,8 @@ const Amba = {
2866
3212
  if (!TOKEN_STORAGE_MODES.includes(tokenStorage)) throw new Error("Amba.configure: tokenStorage must be \"secure\", \"memory\" or \"asyncStorage\"");
2867
3213
  const secureStorage = config.secureStorage;
2868
3214
  if (secureStorage !== void 0 && (typeof secureStorage?.getItem !== "function" || typeof secureStorage?.setItem !== "function" || typeof secureStorage?.removeItem !== "function")) throw new Error("Amba.configure: secureStorage must provide getItem, setItem and removeItem");
3215
+ const configDefaults = readConfigDefaults(config.configDefaults);
3216
+ _pendingConfigDefaults = configDefaults;
2869
3217
  const storagePrefix = config.storagePrefix ?? "amba";
2870
3218
  const persistence = new AsyncStoragePersistence(storagePrefix);
2871
3219
  if (config.events?.batching) await persistence.assertDurable();
@@ -2873,6 +3221,8 @@ const Amba = {
2873
3221
  const vault = await resolveTokenVault(tokenStorage, storagePrefix, secureStorage);
2874
3222
  const identity = await IdentityStore.create(persistence, vault);
2875
3223
  const baseUrl = (config.apiUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
3224
+ const remoteValues = new RemoteValuesCache(persistence, recordKey(baseUrl, config.clientKey), configDefaults ?? {}, () => identity.getAppUserId());
3225
+ await remoteValues.load();
2876
3226
  const sdkVersionHeader = `amba-sdk/${SDK_VERSION} ${`amba-react-native/${SDK_VERSION}`}`;
2877
3227
  const http = new AmbaHttpClient({
2878
3228
  apiKey: config.clientKey,
@@ -2906,6 +3256,11 @@ const Amba = {
2906
3256
  resolvedFunctionsProjectId: null,
2907
3257
  functionsProjectIdInflight: null,
2908
3258
  persistence,
3259
+ remoteValues,
3260
+ refreshedFor: {
3261
+ config: null,
3262
+ flags: null
3263
+ },
2909
3264
  offlineEnabled: false,
2910
3265
  offlineQueue: null,
2911
3266
  eventQueue: null
@@ -3156,7 +3511,10 @@ const Amba = {
3156
3511
  } catch (err) {
3157
3512
  if (typeof console !== "undefined") console.warn("[amba] signOut server call failed", err);
3158
3513
  }
3514
+ const signedOutUser = s.identity.getAppUserId();
3159
3515
  await s.identity.clearSession(rotateAnonymousId);
3516
+ s.remoteValues.clearForSignOut(signedOutUser);
3517
+ await s.remoteValues.flushed();
3160
3518
  try {
3161
3519
  await getOfflineQueue(s).purge();
3162
3520
  } catch (err) {
@@ -3537,54 +3895,69 @@ const Amba = {
3537
3895
  return text;
3538
3896
  }
3539
3897
  } },
3540
- config: { fetch: async () => {
3541
- const s = getState();
3542
- const url = s.http.url(P.config);
3543
- const headers = s.http.buildHeaders();
3544
- let response;
3545
- try {
3546
- response = await (globalThis.fetch ?? fetch)(url, {
3547
- method: "GET",
3548
- headers
3549
- });
3550
- } catch (err) {
3551
- throw new AmbaApiError("NETWORK_ERROR", `Network error: ${err instanceof Error ? err.message : String(err)}`, err);
3552
- }
3553
- const text = await response.text();
3554
- if (!response.ok) {
3555
- const ct = response.headers.get("content-type") ?? "";
3556
- throw mapHttpError(response, text, ct.includes("application/json"));
3557
- }
3558
- let parsed = {};
3559
- if (text) try {
3560
- parsed = JSON.parse(text);
3561
- } catch {
3562
- parsed = {};
3563
- }
3564
- const etag = response.headers.get("etag");
3565
- let version = null;
3566
- if (etag) {
3567
- const cleaned = etag.replace(/^"|"$/g, "");
3568
- const m = cleaned.match(/^(v\d+)/);
3569
- version = m ? m[1] ?? null : cleaned || null;
3570
- }
3571
- return {
3572
- version,
3573
- values: parsed.data ?? {}
3574
- };
3575
- } },
3898
+ config: {
3899
+ fetch: async () => {
3900
+ const s = getState();
3901
+ const ticket = s.remoteValues.ticket();
3902
+ const url = s.http.url(P.config);
3903
+ const headers = s.http.buildHeaders();
3904
+ let response;
3905
+ try {
3906
+ response = await (globalThis.fetch ?? fetch)(url, {
3907
+ method: "GET",
3908
+ headers
3909
+ });
3910
+ } catch (err) {
3911
+ throw new AmbaApiError("NETWORK_ERROR", `Network error: ${err instanceof Error ? err.message : String(err)}`, err);
3912
+ }
3913
+ const text = await response.text();
3914
+ if (!response.ok) {
3915
+ const ct = response.headers.get("content-type") ?? "";
3916
+ throw mapHttpError(response, text, ct.includes("application/json"));
3917
+ }
3918
+ let parsed = {};
3919
+ if (text) try {
3920
+ parsed = JSON.parse(text);
3921
+ } catch {
3922
+ parsed = {};
3923
+ }
3924
+ const etag = response.headers.get("etag");
3925
+ let version = null;
3926
+ if (etag) {
3927
+ const cleaned = etag.replace(/^"|"$/g, "");
3928
+ const m = cleaned.match(/^(v\d+)/);
3929
+ version = m ? m[1] ?? null : cleaned || null;
3930
+ }
3931
+ return s.remoteValues.storeConfig(ticket, {
3932
+ version,
3933
+ values: parsed.data ?? {}
3934
+ }, etag);
3935
+ },
3936
+ getCached: configGetCached
3937
+ },
3576
3938
  flags: {
3577
3939
  fetch: async () => {
3578
- return (await getState().http.get(P.flags)).data;
3940
+ const s = getState();
3941
+ const ticket = s.remoteValues.ticket();
3942
+ const env = await s.http.get(P.flags);
3943
+ return s.remoteValues.storeFlags(ticket, env.data);
3579
3944
  },
3580
3945
  get: async (key) => {
3946
+ const s = getState();
3947
+ const ticket = s.remoteValues.ticket();
3581
3948
  try {
3582
- return (await getState().http.get(`${P.flags}/${encodePathSegment(key)}`)).data;
3949
+ const env = await s.http.get(`${P.flags}/${encodePathSegment(key)}`);
3950
+ return s.remoteValues.upsertFlag(ticket, env.data);
3583
3951
  } catch (err) {
3584
- if (err instanceof AmbaApiError && err.code === "NOT_FOUND") return null;
3952
+ if (err instanceof AmbaApiError && err.code === "NOT_FOUND") {
3953
+ s.remoteValues.removeFlag(ticket, key);
3954
+ return null;
3955
+ }
3585
3956
  throw err;
3586
3957
  }
3587
- }
3958
+ },
3959
+ getCached: flagsGetCached,
3960
+ isEnabled: flagsIsEnabled
3588
3961
  },
3589
3962
  experiments: {
3590
3963
  getVariant: async (key) => {