@layers/amba-react-native 4.0.14 → 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.js CHANGED
@@ -101,6 +101,9 @@ var AmbaHttpClient = class {
101
101
  baseUrl;
102
102
  sdkPlatform;
103
103
  sdkVersionHeader;
104
+ appVersion;
105
+ deviceRegion;
106
+ deviceOs;
104
107
  identity;
105
108
  fetchImpl;
106
109
  ensureSession;
@@ -110,6 +113,9 @@ var AmbaHttpClient = class {
110
113
  this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
111
114
  this.sdkPlatform = opts.sdkPlatform;
112
115
  this.sdkVersionHeader = opts.sdkVersionHeader;
116
+ this.appVersion = opts.appVersion;
117
+ this.deviceRegion = opts.deviceRegion;
118
+ this.deviceOs = opts.deviceOs;
113
119
  this.identity = opts.identity;
114
120
  this.ensureSession = opts.ensureSession;
115
121
  this.onSessionRefused = opts.onSessionRefused;
@@ -138,6 +144,9 @@ var AmbaHttpClient = class {
138
144
  Accept: "application/json",
139
145
  ...extra
140
146
  };
147
+ if (this.appVersion) h["X-App-Version"] = this.appVersion;
148
+ if (this.deviceRegion) h["X-Device-Region"] = this.deviceRegion;
149
+ if (this.deviceOs) h["X-Device-OS"] = this.deviceOs;
141
150
  const tok = this.identity.getSessionToken();
142
151
  if (tok) h.Authorization = `Bearer ${tok}`;
143
152
  return h;
@@ -1763,6 +1772,407 @@ const notifications = {
1763
1772
  }
1764
1773
  };
1765
1774
 
1775
+ //#endregion
1776
+ //#region src/experiment-reporter.ts
1777
+ /** Keys remembered before the oldest are forgotten (bounds memory). */
1778
+ const DEFAULT_MAX_REPORTED = 1e4;
1779
+ var ExperimentAssignmentReporter = class {
1780
+ listeners = /* @__PURE__ */ new Set();
1781
+ reported = /* @__PURE__ */ new Set();
1782
+ constructor(maxReported = DEFAULT_MAX_REPORTED) {
1783
+ this.maxReported = maxReported;
1784
+ }
1785
+ /** Add a listener. Returns a function that removes it. */
1786
+ subscribe(listener) {
1787
+ if (typeof listener !== "function") throw new TypeError("onExperimentAssigned expects a function");
1788
+ this.listeners.add(listener);
1789
+ return () => {
1790
+ this.listeners.delete(listener);
1791
+ };
1792
+ }
1793
+ /**
1794
+ * Call the listeners for `assignment` when this user has not reported this
1795
+ * experiment yet in the session. Returns `assignment` unchanged.
1796
+ */
1797
+ report(assignment) {
1798
+ if (this.listeners.size === 0) return assignment;
1799
+ const id = `${assignment.bucketingId}\u0000${assignment.experimentKey}`;
1800
+ if (this.reported.has(id)) {
1801
+ this.reported.delete(id);
1802
+ this.reported.add(id);
1803
+ return assignment;
1804
+ }
1805
+ this.reported.add(id);
1806
+ if (this.reported.size > this.maxReported) {
1807
+ const oldest = this.reported.values().next().value;
1808
+ if (oldest !== void 0) this.reported.delete(oldest);
1809
+ }
1810
+ for (const listener of Array.from(this.listeners)) try {
1811
+ const result = listener({ ...assignment });
1812
+ if (result && typeof result.then === "function") result.then(void 0, logListenerError);
1813
+ } catch (err) {
1814
+ logListenerError(err);
1815
+ }
1816
+ return assignment;
1817
+ }
1818
+ /** Start a new session: every experiment reports again. */
1819
+ reset() {
1820
+ this.reported.clear();
1821
+ }
1822
+ };
1823
+ function logListenerError(err) {
1824
+ console.warn("[amba] onExperimentAssigned listener threw", err);
1825
+ }
1826
+
1827
+ //#endregion
1828
+ //#region src/remote-values.ts
1829
+ /** Prefix of the per-project AsyncStorage record key. */
1830
+ const CACHE_KEY_PREFIX = "amba_rc_v1_";
1831
+ /** Slot for values evaluated without a signed-in user. */
1832
+ const ANON_SLOT = "anon";
1833
+ /** Most user slots one project record keeps. */
1834
+ const MAX_SLOTS = 5;
1835
+ const TWO_32 = 4294967296;
1836
+ /**
1837
+ * 64-bit FNV-1a over the string's UTF-16 code units (low byte, then high
1838
+ * byte), as 16 hex digits. Computed in two 32-bit halves: some React Native
1839
+ * engines (JavaScriptCore on older Android builds) have no BigInt.
1840
+ */
1841
+ function fnv1a64Hex(text) {
1842
+ let hi = 3421674724;
1843
+ let lo = 2216829733;
1844
+ const step = (byte) => {
1845
+ lo = (lo ^ byte) >>> 0;
1846
+ const low = lo * 435;
1847
+ const carry = Math.floor(low / TWO_32);
1848
+ const high = hi * 435 + carry + (lo << 8 >>> 0);
1849
+ lo = low % TWO_32;
1850
+ hi = high % TWO_32;
1851
+ };
1852
+ for (let i = 0; i < text.length; i++) {
1853
+ const unit = text.charCodeAt(i);
1854
+ step(unit & 255);
1855
+ step(unit >>> 8);
1856
+ }
1857
+ return hi.toString(16).padStart(8, "0") + lo.toString(16).padStart(8, "0");
1858
+ }
1859
+ /** The record key for a project: a hash of the base URL and client key. */
1860
+ function recordKey(baseUrl, clientKey) {
1861
+ return `${CACHE_KEY_PREFIX}${fnv1a64Hex(`${baseUrl.replace(/\/+$/, "")}\n${clientKey}`)}`;
1862
+ }
1863
+ /** The slot key for an identity: `u` + a hash of the user id, or `anon`. */
1864
+ function slotKey(appUserId) {
1865
+ return appUserId ? `u${fnv1a64Hex(appUserId)}` : ANON_SLOT;
1866
+ }
1867
+ /**
1868
+ * The user a config response was evaluated for, from its `ETag`:
1869
+ * `"<version>:<app user id>:<segments>"` for a signed-in caller, `"<version>"`
1870
+ * for every other one (no session, or an expired or revoked token, which the
1871
+ * server answers with each key's default value). `undefined` without an
1872
+ * `ETag`; `null` for an evaluation for nobody.
1873
+ */
1874
+ function etagEvaluatedFor(etag) {
1875
+ if (!etag) return void 0;
1876
+ const parts = etag.trim().replace(/^W\//, "").replace(/^"|"$/g, "").split(":");
1877
+ return parts.length > 1 && parts[1] ? parts[1] : null;
1878
+ }
1879
+ function isPlainObject(value) {
1880
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1881
+ }
1882
+ function validSlot(raw) {
1883
+ if (!isPlainObject(raw)) return null;
1884
+ const slot = { saved_at: typeof raw["saved_at"] === "number" ? raw["saved_at"] : 0 };
1885
+ const config = raw["config"];
1886
+ if (isPlainObject(config) && isPlainObject(config["values"])) slot.config = {
1887
+ version: typeof config["version"] === "string" ? config["version"] : null,
1888
+ values: config["values"]
1889
+ };
1890
+ const flags = raw["flags"];
1891
+ if (Array.isArray(flags)) slot.flags = flags.filter((f) => isPlainObject(f) && typeof f["name"] === "string" && typeof f["enabled"] === "boolean");
1892
+ return slot.config || slot.flags ? slot : null;
1893
+ }
1894
+ function upsert(list, flag) {
1895
+ const i = list.findIndex((f) => f.name === flag.name);
1896
+ if (i >= 0) list[i] = flag;
1897
+ else list.push(flag);
1898
+ }
1899
+ var RemoteValuesCache = class {
1900
+ record = {
1901
+ v: 1,
1902
+ slots: {}
1903
+ };
1904
+ /** The slot key the held values were resolved for. */
1905
+ viewFor = null;
1906
+ config = null;
1907
+ flags = null;
1908
+ /** Bumped by every sign-out; an older ticket's response is not cached. */
1909
+ generation = 0;
1910
+ writes = Promise.resolve();
1911
+ constructor(store, key, defaults, currentUser) {
1912
+ this.store = store;
1913
+ this.key = key;
1914
+ this.defaults = defaults;
1915
+ this.currentUser = currentUser;
1916
+ }
1917
+ /** Read the project's record from storage. `configure()` awaits it. */
1918
+ async load() {
1919
+ let raw = null;
1920
+ try {
1921
+ raw = await this.store.read(this.key);
1922
+ } catch {
1923
+ return;
1924
+ }
1925
+ if (!raw) return;
1926
+ try {
1927
+ const parsed = JSON.parse(raw);
1928
+ const slots = {};
1929
+ if (isPlainObject(parsed) && isPlainObject(parsed["slots"])) for (const [k, v] of Object.entries(parsed["slots"])) {
1930
+ const slot = validSlot(v);
1931
+ if (slot) slots[k] = slot;
1932
+ }
1933
+ this.record = {
1934
+ v: 1,
1935
+ slots
1936
+ };
1937
+ } catch {
1938
+ this.record = {
1939
+ v: 1,
1940
+ slots: {}
1941
+ };
1942
+ this.persist();
1943
+ }
1944
+ }
1945
+ /** Take a ticket for a request about to be sent for the current user. */
1946
+ ticket() {
1947
+ return {
1948
+ generation: this.generation,
1949
+ requestedFor: this.currentUser()
1950
+ };
1951
+ }
1952
+ /** The value for config `key` (fetched, cached, then default); `undefined` when none. */
1953
+ configValue(key) {
1954
+ this.ensureView();
1955
+ const values = this.config?.values;
1956
+ if (values && Object.prototype.hasOwnProperty.call(values, key)) {
1957
+ const v = values[key];
1958
+ if (v !== null && v !== void 0) return v;
1959
+ }
1960
+ return this.defaultFor(key);
1961
+ }
1962
+ /** The held assignment for flag `key`, or `null`. */
1963
+ flag(key) {
1964
+ this.ensureView();
1965
+ return this.flags?.find((f) => f.name === key) ?? null;
1966
+ }
1967
+ /** Whether flag `key` is on: the held assignment, then a boolean default. */
1968
+ flagEnabled(key) {
1969
+ const flag = this.flag(key);
1970
+ if (flag) return flag.enabled;
1971
+ const d = this.defaultFor(key);
1972
+ return typeof d === "boolean" ? d : void 0;
1973
+ }
1974
+ /**
1975
+ * Hold and persist a fetched bundle; returns it. A bundle the server
1976
+ * evaluated for nobody although the request carried a user (an expired or
1977
+ * revoked session) goes to the `anon` slot and leaves the user's values
1978
+ * alone; one evaluated for another user is not cached.
1979
+ */
1980
+ storeConfig(ticket, bundle, etag) {
1981
+ if (!isPlainObject(bundle.values)) return bundle;
1982
+ const evaluatedFor = etagEvaluatedFor(etag);
1983
+ let target;
1984
+ if (ticket.requestedFor && evaluatedFor === null) target = ANON_SLOT;
1985
+ else if (typeof evaluatedFor === "string" && evaluatedFor !== ticket.requestedFor) return bundle;
1986
+ this.write(ticket, target, (slot, held) => {
1987
+ slot.config = bundle;
1988
+ if (held) this.config = bundle;
1989
+ });
1990
+ return bundle;
1991
+ }
1992
+ /** Hold and persist a fetched flag list (it replaces the held one). */
1993
+ storeFlags(ticket, flags) {
1994
+ this.write(ticket, void 0, (slot, held) => {
1995
+ slot.flags = [...flags];
1996
+ if (held) this.flags = [...flags];
1997
+ });
1998
+ return flags;
1999
+ }
2000
+ /** Hold and persist one flag from a single-flag lookup; returns it. */
2001
+ upsertFlag(ticket, flag) {
2002
+ this.write(ticket, void 0, (slot, held) => {
2003
+ this.adoptHeldFlags(slot, held);
2004
+ slot.flags = slot.flags ?? [];
2005
+ upsert(slot.flags, flag);
2006
+ if (held) {
2007
+ this.flags = this.flags ?? [];
2008
+ upsert(this.flags, flag);
2009
+ }
2010
+ });
2011
+ return flag;
2012
+ }
2013
+ /** Drop flag `key` after a single-flag lookup found it off or unknown. */
2014
+ removeFlag(ticket, key) {
2015
+ this.write(ticket, void 0, (slot, held) => {
2016
+ this.adoptHeldFlags(slot, held);
2017
+ if (slot.flags) slot.flags = slot.flags.filter((f) => f.name !== key);
2018
+ if (held && this.flags) this.flags = this.flags.filter((f) => f.name !== key);
2019
+ });
2020
+ }
2021
+ /**
2022
+ * Sign-out: delete the signed-out user's slot and drop the held values;
2023
+ * later lookups serve the `anon` slot, then the defaults.
2024
+ */
2025
+ clearForSignOut(signedOutUser) {
2026
+ this.generation += 1;
2027
+ this.config = null;
2028
+ this.flags = null;
2029
+ this.viewFor = null;
2030
+ if (!signedOutUser) return;
2031
+ const doomed = slotKey(signedOutUser);
2032
+ if (this.record.slots[doomed]) {
2033
+ delete this.record.slots[doomed];
2034
+ this.persist();
2035
+ }
2036
+ }
2037
+ /** Resolves when every write so far reached storage. */
2038
+ flushed() {
2039
+ return this.writes;
2040
+ }
2041
+ /**
2042
+ * Before a single-flag change to the current user's slot: when the slot
2043
+ * holds no flag list and the served list is the signed-out one, the
2044
+ * user's slot starts from that list, so after a relaunch the user reads
2045
+ * the same flags the change was made on.
2046
+ */
2047
+ adoptHeldFlags(slot, held) {
2048
+ if (held && !slot.flags && this.flags) slot.flags = [...this.flags];
2049
+ }
2050
+ defaultFor(key) {
2051
+ if (!Object.prototype.hasOwnProperty.call(this.defaults, key)) return void 0;
2052
+ const v = this.defaults[key];
2053
+ return v === null ? void 0 : v;
2054
+ }
2055
+ /**
2056
+ * `held` is `true` when the slot written is the one the current identity
2057
+ * reads, so the served values change with it.
2058
+ */
2059
+ write(ticket, targetOverride, apply) {
2060
+ const current = slotKey(this.currentUser());
2061
+ if (ticket.generation !== this.generation || slotKey(ticket.requestedFor) !== current) return;
2062
+ this.ensureView();
2063
+ const target = targetOverride ?? current;
2064
+ const existing = this.record.slots[target];
2065
+ const slot = existing ? {
2066
+ saved_at: existing.saved_at,
2067
+ ...existing.config ? { config: existing.config } : {},
2068
+ ...existing.flags ? { flags: [...existing.flags] } : {}
2069
+ } : { saved_at: 0 };
2070
+ apply(slot, target === current);
2071
+ if (!slot.config && !slot.flags) {
2072
+ if (!existing) return;
2073
+ delete this.record.slots[target];
2074
+ } else {
2075
+ slot.saved_at = Date.now();
2076
+ this.record.slots[target] = slot;
2077
+ this.prune(target);
2078
+ }
2079
+ this.persist();
2080
+ if (target !== current) this.viewFor = null;
2081
+ }
2082
+ /** Serve the current identity's own slot, the `anon` slot for the rest. */
2083
+ ensureView() {
2084
+ const current = slotKey(this.currentUser());
2085
+ if (this.viewFor === current) return;
2086
+ const own = this.record.slots[current];
2087
+ const anon = current === ANON_SLOT ? void 0 : this.record.slots[ANON_SLOT];
2088
+ const config = own?.config ?? anon?.config;
2089
+ const flags = own?.flags ?? anon?.flags;
2090
+ this.config = config ?? null;
2091
+ this.flags = flags ? [...flags] : null;
2092
+ this.viewFor = current;
2093
+ }
2094
+ /**
2095
+ * Keep the MAX_SLOTS most recently written user slots; `keep` and the
2096
+ * signed-out slot (read before sign-in and for every user's gaps) stay.
2097
+ */
2098
+ prune(keep) {
2099
+ const keys = Object.keys(this.record.slots).filter((k) => k !== ANON_SLOT);
2100
+ while (keys.length > MAX_SLOTS) {
2101
+ let oldest = null;
2102
+ for (const k of keys) {
2103
+ if (k === keep) continue;
2104
+ if (oldest === null || this.record.slots[k].saved_at < this.record.slots[oldest].saved_at) oldest = k;
2105
+ }
2106
+ if (oldest === null) break;
2107
+ delete this.record.slots[oldest];
2108
+ keys.splice(keys.indexOf(oldest), 1);
2109
+ }
2110
+ }
2111
+ persist() {
2112
+ const text = Object.keys(this.record.slots).length === 0 ? null : JSON.stringify(this.record);
2113
+ this.writes = this.writes.then(() => text === null ? this.store.delete(this.key) : this.store.write(this.key, text)).catch(() => {});
2114
+ }
2115
+ };
2116
+
2117
+ //#endregion
2118
+ //#region src/client-info.ts
2119
+ /**
2120
+ * Values for the `X-App-Version`, `X-Device-Region` and `X-Device-OS`
2121
+ * request headers. Each is omitted when unknown, so every helper here
2122
+ * returns `undefined` for a value the header can't carry as-is.
2123
+ */
2124
+ /** Longest `X-App-Version` value sent (matches the core's limit). */
2125
+ const MAX_APP_VERSION_LENGTH = 64;
2126
+ /**
2127
+ * An app version for `X-App-Version`: the text before the first whitespace
2128
+ * (`"2.3.1 (45)"` sends `2.3.1`), which must start with an ASCII letter or
2129
+ * digit, hold only letters, digits and `.` `_` `+` `-`, and be at most
2130
+ * {@link MAX_APP_VERSION_LENGTH} characters: the shape the API records for
2131
+ * segment targeting. Anything else returns `undefined`.
2132
+ */
2133
+ function normalizeAppVersion(raw) {
2134
+ if (typeof raw !== "string") return void 0;
2135
+ const v = raw.trim().split(/\s+/)[0] ?? "";
2136
+ if (v.length === 0 || v.length > MAX_APP_VERSION_LENGTH) return void 0;
2137
+ return /^[0-9A-Za-z][0-9A-Za-z._+-]*$/.test(v) ? v : void 0;
2138
+ }
2139
+ /**
2140
+ * The region subtag of a BCP 47 locale tag (`en-US` → `US`, `zh-Hant-TW` →
2141
+ * `TW`, POSIX `fr_FR.UTF-8` → `FR`, iOS `en_US@rg=gbzzzz` → `US`),
2142
+ * uppercased. Returns `undefined` when the tag carries no two-letter region
2143
+ * (`en`, `es-419`, `sr-Latn`).
2144
+ */
2145
+ function regionFromLocale(tag) {
2146
+ if (typeof tag !== "string" || tag.length === 0) return void 0;
2147
+ const parts = tag.split(/[.@]/)[0].split(/[-_]/);
2148
+ for (let i = 1; i < parts.length; i++) {
2149
+ const part = parts[i];
2150
+ if (part.length === 1) break;
2151
+ if (/^[A-Za-z]{2}$/.test(part)) return part.toUpperCase();
2152
+ }
2153
+ }
2154
+ const DEVICE_OS_VALUES = [
2155
+ "ios",
2156
+ "android",
2157
+ "web",
2158
+ "macos",
2159
+ "windows",
2160
+ "linux",
2161
+ "other"
2162
+ ];
2163
+ /**
2164
+ * Map a platform OS name (React Native's `Platform.OS`, for example) to an
2165
+ * `X-Device-OS` value. Listed names pass through lowercased; any other
2166
+ * non-empty name is a known-but-unlisted OS and maps to `"other"`. Empty or
2167
+ * missing returns `undefined`.
2168
+ */
2169
+ function deviceOsFromPlatform(os) {
2170
+ if (typeof os !== "string") return void 0;
2171
+ const v = os.trim().toLowerCase();
2172
+ if (v.length === 0) return void 0;
2173
+ return DEVICE_OS_VALUES.includes(v) ? v : "other";
2174
+ }
2175
+
1766
2176
  //#endregion
1767
2177
  //#region src/realtime.ts
1768
2178
  const INITIAL_BACKOFF_MS = 500;
@@ -2086,7 +2496,7 @@ function delay(ms) {
2086
2496
  //#endregion
2087
2497
  //#region src/index.ts
2088
2498
  /** SDK semver — burned in at build time. Bump alongside package.json. */
2089
- const SDK_VERSION = "4.0.14";
2499
+ const SDK_VERSION = "4.2.0";
2090
2500
  /**
2091
2501
  * Default host that serves deployed functions. `Amba.functions.invoke(name)`
2092
2502
  * targets `https://<projectId>.<DEFAULT_FUNCTIONS_HOST>/<name>` unless a
@@ -2123,6 +2533,69 @@ function realtimeAuthHeaders() {
2123
2533
  };
2124
2534
  }
2125
2535
  const authSubscribers = /* @__PURE__ */ new Set();
2536
+ /**
2537
+ * `Amba.onExperimentAssigned` listeners. `experiments.getVariant` reports
2538
+ * each result here; the reporter calls the listeners once per experiment per
2539
+ * user session. A sign-out (any auth change to no session) and `configure`
2540
+ * start a new session.
2541
+ */
2542
+ const experimentReporter = new ExperimentAssignmentReporter();
2543
+ /**
2544
+ * `configDefaults` from the latest `configure()` call, recorded before
2545
+ * configure reads the AsyncStorage cache. `config.getCached` and
2546
+ * `flags.isEnabled` serve them on a first render that runs while
2547
+ * `configure()` is still in flight.
2548
+ */
2549
+ let _pendingConfigDefaults = null;
2550
+ /** Validate `AmbaConfig.configDefaults`: absent, or a plain object. */
2551
+ function readConfigDefaults(value) {
2552
+ if (value === void 0 || value === null) return null;
2553
+ if (typeof value !== "object" || Array.isArray(value)) throw new AmbaApiError("INVALID_CONFIG", "Amba.configure: configDefaults must be an object of config key to value");
2554
+ return value;
2555
+ }
2556
+ /** The pending `configDefaults` entry for `key`, ignoring inherited properties. */
2557
+ function pendingDefault(key) {
2558
+ const defaults = _pendingConfigDefaults;
2559
+ if (!defaults || !Object.prototype.hasOwnProperty.call(defaults, key)) return;
2560
+ const value = defaults[key];
2561
+ return value === null ? void 0 : value;
2562
+ }
2563
+ /** `"array"` for arrays, else `typeof` — the JSON kind of a value. */
2564
+ function jsonKind(value) {
2565
+ return Array.isArray(value) ? "array" : typeof value;
2566
+ }
2567
+ function configGetCached(key, defaultValue) {
2568
+ if (state) refreshOnFirstRead(state, "config");
2569
+ const raw = state ? state.remoteValues.configValue(key) : pendingDefault(key);
2570
+ if (raw === void 0 || raw === null) return defaultValue;
2571
+ if (defaultValue !== void 0 && defaultValue !== null && jsonKind(raw) !== jsonKind(defaultValue)) return defaultValue;
2572
+ return raw;
2573
+ }
2574
+ /** `Amba.flags.isEnabled`. */
2575
+ function flagsIsEnabled(key, defaultValue = false) {
2576
+ if (state) refreshOnFirstRead(state, "flags");
2577
+ const held = state ? state.remoteValues.flagEnabled(key) : pendingDefault(key);
2578
+ return typeof held === "boolean" ? held : defaultValue;
2579
+ }
2580
+ /** `Amba.flags.getCached`. */
2581
+ function flagsGetCached(key) {
2582
+ if (!state) return null;
2583
+ refreshOnFirstRead(state, "flags");
2584
+ return state.remoteValues.flag(key);
2585
+ }
2586
+ /**
2587
+ * The first on-device read of config (or flags) for each identity after
2588
+ * `configure()` starts one background fetch that replaces the cached values:
2589
+ * once at launch, and once more after a sign-in or sign-out changes who is
2590
+ * reading. Apps that never read cached values make no extra request. A
2591
+ * failure (offline) leaves the cached values in place.
2592
+ */
2593
+ function refreshOnFirstRead(s, kind) {
2594
+ const identity = s.identity.getAppUserId() ?? "";
2595
+ if (s.refreshedFor[kind] === identity) return;
2596
+ s.refreshedFor[kind] = identity;
2597
+ Promise.resolve().then(() => kind === "config" ? Amba.config.fetch() : Amba.flags.fetch()).catch(() => {});
2598
+ }
2126
2599
  function snapshotSession(user) {
2127
2600
  if (!user) return null;
2128
2601
  return {
@@ -2133,6 +2606,7 @@ function snapshotSession(user) {
2133
2606
  };
2134
2607
  }
2135
2608
  function notifyAuthSubscribers(session) {
2609
+ if (session === null) experimentReporter.reset();
2136
2610
  for (const cb of authSubscribers) try {
2137
2611
  cb(session);
2138
2612
  } catch (err) {
@@ -2549,6 +3023,7 @@ const P = {
2549
3023
  aiAnthropic: "/v1/client/ai/anthropic/messages",
2550
3024
  config: "/v1/client/config",
2551
3025
  flags: "/v1/client/flags",
3026
+ experiments: "/v1/client/experiments",
2552
3027
  diagnosticsPing: "/v1/client/diagnostics/ping",
2553
3028
  achievements: "/v1/client/achievements",
2554
3029
  challenges: "/v1/client/challenges",
@@ -2692,6 +3167,39 @@ async function deleteDirect(s, name, id) {
2692
3167
  return await s.http.delete(`${P.collections}/${encodePathSegment(name)}/${encodePathSegment(id)}`) ?? { data: { deleted: true } };
2693
3168
  }
2694
3169
  /**
3170
+ * The device OS for `X-Device-OS` from React Native's `Platform.OS`
3171
+ * (`ios`, `android`, `web` for react-native-web, `macos`, `windows`).
3172
+ * `undefined` outside React Native (Node, tests).
3173
+ */
3174
+ function detectDeviceOs() {
3175
+ try {
3176
+ const os = require("react-native").Platform?.OS;
3177
+ return deviceOsFromPlatform(typeof os === "string" ? os : void 0);
3178
+ } catch {
3179
+ return;
3180
+ }
3181
+ }
3182
+ /**
3183
+ * The device locale's region for `X-Device-Region`: the iOS region setting
3184
+ * (`AppleLocale`) or Android's locale, falling back to the JS runtime's
3185
+ * default locale. `undefined` when none carries a two-letter region.
3186
+ */
3187
+ function detectDeviceRegion() {
3188
+ try {
3189
+ const rn = require("react-native");
3190
+ const ios = (rn.NativeModules?.["SettingsManager"])?.settings;
3191
+ const android = rn.NativeModules?.["I18nManager"];
3192
+ const tag = ios?.AppleLocale ?? android?.localeIdentifier;
3193
+ const region = regionFromLocale(typeof tag === "string" ? tag : void 0);
3194
+ if (region) return region;
3195
+ } catch {}
3196
+ try {
3197
+ return regionFromLocale(Intl.DateTimeFormat().resolvedOptions().locale);
3198
+ } catch {
3199
+ return;
3200
+ }
3201
+ }
3202
+ /**
2695
3203
  * Top-level SDK namespace for React Native.
2696
3204
  */
2697
3205
  const Amba = {
@@ -2710,6 +3218,8 @@ const Amba = {
2710
3218
  if (!TOKEN_STORAGE_MODES.includes(tokenStorage)) throw new Error("Amba.configure: tokenStorage must be \"secure\", \"memory\" or \"asyncStorage\"");
2711
3219
  const secureStorage = config.secureStorage;
2712
3220
  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");
3221
+ const configDefaults = readConfigDefaults(config.configDefaults);
3222
+ _pendingConfigDefaults = configDefaults;
2713
3223
  const storagePrefix = config.storagePrefix ?? "amba";
2714
3224
  const persistence = new AsyncStoragePersistence(storagePrefix);
2715
3225
  if (config.events?.batching) await persistence.assertDurable();
@@ -2717,12 +3227,17 @@ const Amba = {
2717
3227
  const vault = await resolveTokenVault(tokenStorage, storagePrefix, secureStorage);
2718
3228
  const identity = await IdentityStore.create(persistence, vault);
2719
3229
  const baseUrl = (config.apiUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
3230
+ const remoteValues = new RemoteValuesCache(persistence, recordKey(baseUrl, config.clientKey), configDefaults ?? {}, () => identity.getAppUserId());
3231
+ await remoteValues.load();
2720
3232
  const sdkVersionHeader = `amba-sdk/${SDK_VERSION} ${`amba-react-native/${SDK_VERSION}`}`;
2721
3233
  const http = new AmbaHttpClient({
2722
3234
  apiKey: config.clientKey,
2723
3235
  baseUrl,
2724
3236
  sdkPlatform: "react-native",
2725
3237
  sdkVersionHeader,
3238
+ appVersion: normalizeAppVersion(config.appVersion),
3239
+ deviceRegion: detectDeviceRegion(),
3240
+ deviceOs: detectDeviceOs(),
2726
3241
  identity,
2727
3242
  ensureSession: config.autoAnonymousSession === false ? void 0 : async (path) => {
2728
3243
  if (pathSkipsLazySession(path)) return;
@@ -2735,6 +3250,7 @@ const Amba = {
2735
3250
  if (prior?.offlineQueue) await prior.offlineQueue.dispose();
2736
3251
  prior?.detachEventAppState?.();
2737
3252
  if (prior?.eventQueue) await prior.eventQueue.dispose();
3253
+ experimentReporter.reset();
2738
3254
  const nextState = {
2739
3255
  http,
2740
3256
  identity,
@@ -2746,6 +3262,11 @@ const Amba = {
2746
3262
  resolvedFunctionsProjectId: null,
2747
3263
  functionsProjectIdInflight: null,
2748
3264
  persistence,
3265
+ remoteValues,
3266
+ refreshedFor: {
3267
+ config: null,
3268
+ flags: null
3269
+ },
2749
3270
  offlineEnabled: false,
2750
3271
  offlineQueue: null,
2751
3272
  eventQueue: null
@@ -2996,7 +3517,10 @@ const Amba = {
2996
3517
  } catch (err) {
2997
3518
  if (typeof console !== "undefined") console.warn("[amba] signOut server call failed", err);
2998
3519
  }
3520
+ const signedOutUser = s.identity.getAppUserId();
2999
3521
  await s.identity.clearSession(rotateAnonymousId);
3522
+ s.remoteValues.clearForSignOut(signedOutUser);
3523
+ await s.remoteValues.flushed();
3000
3524
  try {
3001
3525
  await getOfflineQueue(s).purge();
3002
3526
  } catch (err) {
@@ -3377,55 +3901,86 @@ const Amba = {
3377
3901
  return text;
3378
3902
  }
3379
3903
  } },
3380
- config: { fetch: async () => {
3381
- const s = getState();
3382
- const url = s.http.url(P.config);
3383
- const headers = s.http.buildHeaders();
3384
- let response;
3385
- try {
3386
- response = await (globalThis.fetch ?? fetch)(url, {
3387
- method: "GET",
3388
- headers
3389
- });
3390
- } catch (err) {
3391
- throw new AmbaApiError("NETWORK_ERROR", `Network error: ${err instanceof Error ? err.message : String(err)}`, err);
3392
- }
3393
- const text = await response.text();
3394
- if (!response.ok) {
3395
- const ct = response.headers.get("content-type") ?? "";
3396
- throw mapHttpError(response, text, ct.includes("application/json"));
3397
- }
3398
- let parsed = {};
3399
- if (text) try {
3400
- parsed = JSON.parse(text);
3401
- } catch {
3402
- parsed = {};
3403
- }
3404
- const etag = response.headers.get("etag");
3405
- let version = null;
3406
- if (etag) {
3407
- const cleaned = etag.replace(/^"|"$/g, "");
3408
- const m = cleaned.match(/^(v\d+)/);
3409
- version = m ? m[1] ?? null : cleaned || null;
3410
- }
3411
- return {
3412
- version,
3413
- values: parsed.data ?? {}
3414
- };
3415
- } },
3904
+ config: {
3905
+ fetch: async () => {
3906
+ const s = getState();
3907
+ const ticket = s.remoteValues.ticket();
3908
+ const url = s.http.url(P.config);
3909
+ const headers = s.http.buildHeaders();
3910
+ let response;
3911
+ try {
3912
+ response = await (globalThis.fetch ?? fetch)(url, {
3913
+ method: "GET",
3914
+ headers
3915
+ });
3916
+ } catch (err) {
3917
+ throw new AmbaApiError("NETWORK_ERROR", `Network error: ${err instanceof Error ? err.message : String(err)}`, err);
3918
+ }
3919
+ const text = await response.text();
3920
+ if (!response.ok) {
3921
+ const ct = response.headers.get("content-type") ?? "";
3922
+ throw mapHttpError(response, text, ct.includes("application/json"));
3923
+ }
3924
+ let parsed = {};
3925
+ if (text) try {
3926
+ parsed = JSON.parse(text);
3927
+ } catch {
3928
+ parsed = {};
3929
+ }
3930
+ const etag = response.headers.get("etag");
3931
+ let version = null;
3932
+ if (etag) {
3933
+ const cleaned = etag.replace(/^"|"$/g, "");
3934
+ const m = cleaned.match(/^(v\d+)/);
3935
+ version = m ? m[1] ?? null : cleaned || null;
3936
+ }
3937
+ return s.remoteValues.storeConfig(ticket, {
3938
+ version,
3939
+ values: parsed.data ?? {}
3940
+ }, etag);
3941
+ },
3942
+ getCached: configGetCached
3943
+ },
3416
3944
  flags: {
3417
3945
  fetch: async () => {
3418
- return (await getState().http.get(P.flags)).data;
3946
+ const s = getState();
3947
+ const ticket = s.remoteValues.ticket();
3948
+ const env = await s.http.get(P.flags);
3949
+ return s.remoteValues.storeFlags(ticket, env.data);
3419
3950
  },
3420
3951
  get: async (key) => {
3952
+ const s = getState();
3953
+ const ticket = s.remoteValues.ticket();
3421
3954
  try {
3422
- return (await getState().http.get(`${P.flags}/${encodePathSegment(key)}`)).data;
3955
+ const env = await s.http.get(`${P.flags}/${encodePathSegment(key)}`);
3956
+ return s.remoteValues.upsertFlag(ticket, env.data);
3423
3957
  } catch (err) {
3424
- if (err instanceof AmbaApiError && err.code === "NOT_FOUND") return null;
3958
+ if (err instanceof AmbaApiError && err.code === "NOT_FOUND") {
3959
+ s.remoteValues.removeFlag(ticket, key);
3960
+ return null;
3961
+ }
3425
3962
  throw err;
3426
3963
  }
3964
+ },
3965
+ getCached: flagsGetCached,
3966
+ isEnabled: flagsIsEnabled
3967
+ },
3968
+ experiments: {
3969
+ getVariant: async (key) => {
3970
+ const s = getState();
3971
+ const userBefore = s.identity.getAppUserId();
3972
+ const env = await s.http.get(`${P.experiments}/${encodePathSegment(key)}/assignment`);
3973
+ return experimentReporter.report({
3974
+ experimentKey: env.data.experiment_key,
3975
+ variant: env.data.variant,
3976
+ bucketingId: userBefore ?? s.identity.getAppUserId() ?? ""
3977
+ });
3978
+ },
3979
+ trackExposure: async (key) => {
3980
+ await getState().http.post(`${P.experiments}/${encodePathSegment(key)}/exposure`, {});
3427
3981
  }
3428
3982
  },
3983
+ onExperimentAssigned: (listener) => experimentReporter.subscribe(listener),
3429
3984
  diagnostics: { ping: async () => {
3430
3985
  return await getState().http.post(P.diagnosticsPing, {});
3431
3986
  } },