@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.cjs +595 -40
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +109 -3
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +109 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +595 -40
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -95,6 +95,9 @@ var AmbaHttpClient = class {
|
|
|
95
95
|
baseUrl;
|
|
96
96
|
sdkPlatform;
|
|
97
97
|
sdkVersionHeader;
|
|
98
|
+
appVersion;
|
|
99
|
+
deviceRegion;
|
|
100
|
+
deviceOs;
|
|
98
101
|
identity;
|
|
99
102
|
fetchImpl;
|
|
100
103
|
ensureSession;
|
|
@@ -104,6 +107,9 @@ var AmbaHttpClient = class {
|
|
|
104
107
|
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
105
108
|
this.sdkPlatform = opts.sdkPlatform;
|
|
106
109
|
this.sdkVersionHeader = opts.sdkVersionHeader;
|
|
110
|
+
this.appVersion = opts.appVersion;
|
|
111
|
+
this.deviceRegion = opts.deviceRegion;
|
|
112
|
+
this.deviceOs = opts.deviceOs;
|
|
107
113
|
this.identity = opts.identity;
|
|
108
114
|
this.ensureSession = opts.ensureSession;
|
|
109
115
|
this.onSessionRefused = opts.onSessionRefused;
|
|
@@ -132,6 +138,9 @@ var AmbaHttpClient = class {
|
|
|
132
138
|
Accept: "application/json",
|
|
133
139
|
...extra
|
|
134
140
|
};
|
|
141
|
+
if (this.appVersion) h["X-App-Version"] = this.appVersion;
|
|
142
|
+
if (this.deviceRegion) h["X-Device-Region"] = this.deviceRegion;
|
|
143
|
+
if (this.deviceOs) h["X-Device-OS"] = this.deviceOs;
|
|
135
144
|
const tok = this.identity.getSessionToken();
|
|
136
145
|
if (tok) h.Authorization = `Bearer ${tok}`;
|
|
137
146
|
return h;
|
|
@@ -1757,6 +1766,407 @@ const notifications = {
|
|
|
1757
1766
|
}
|
|
1758
1767
|
};
|
|
1759
1768
|
|
|
1769
|
+
//#endregion
|
|
1770
|
+
//#region src/experiment-reporter.ts
|
|
1771
|
+
/** Keys remembered before the oldest are forgotten (bounds memory). */
|
|
1772
|
+
const DEFAULT_MAX_REPORTED = 1e4;
|
|
1773
|
+
var ExperimentAssignmentReporter = class {
|
|
1774
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1775
|
+
reported = /* @__PURE__ */ new Set();
|
|
1776
|
+
constructor(maxReported = DEFAULT_MAX_REPORTED) {
|
|
1777
|
+
this.maxReported = maxReported;
|
|
1778
|
+
}
|
|
1779
|
+
/** Add a listener. Returns a function that removes it. */
|
|
1780
|
+
subscribe(listener) {
|
|
1781
|
+
if (typeof listener !== "function") throw new TypeError("onExperimentAssigned expects a function");
|
|
1782
|
+
this.listeners.add(listener);
|
|
1783
|
+
return () => {
|
|
1784
|
+
this.listeners.delete(listener);
|
|
1785
|
+
};
|
|
1786
|
+
}
|
|
1787
|
+
/**
|
|
1788
|
+
* Call the listeners for `assignment` when this user has not reported this
|
|
1789
|
+
* experiment yet in the session. Returns `assignment` unchanged.
|
|
1790
|
+
*/
|
|
1791
|
+
report(assignment) {
|
|
1792
|
+
if (this.listeners.size === 0) return assignment;
|
|
1793
|
+
const id = `${assignment.bucketingId}\u0000${assignment.experimentKey}`;
|
|
1794
|
+
if (this.reported.has(id)) {
|
|
1795
|
+
this.reported.delete(id);
|
|
1796
|
+
this.reported.add(id);
|
|
1797
|
+
return assignment;
|
|
1798
|
+
}
|
|
1799
|
+
this.reported.add(id);
|
|
1800
|
+
if (this.reported.size > this.maxReported) {
|
|
1801
|
+
const oldest = this.reported.values().next().value;
|
|
1802
|
+
if (oldest !== void 0) this.reported.delete(oldest);
|
|
1803
|
+
}
|
|
1804
|
+
for (const listener of Array.from(this.listeners)) try {
|
|
1805
|
+
const result = listener({ ...assignment });
|
|
1806
|
+
if (result && typeof result.then === "function") result.then(void 0, logListenerError);
|
|
1807
|
+
} catch (err) {
|
|
1808
|
+
logListenerError(err);
|
|
1809
|
+
}
|
|
1810
|
+
return assignment;
|
|
1811
|
+
}
|
|
1812
|
+
/** Start a new session: every experiment reports again. */
|
|
1813
|
+
reset() {
|
|
1814
|
+
this.reported.clear();
|
|
1815
|
+
}
|
|
1816
|
+
};
|
|
1817
|
+
function logListenerError(err) {
|
|
1818
|
+
console.warn("[amba] onExperimentAssigned listener threw", err);
|
|
1819
|
+
}
|
|
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
|
+
|
|
2111
|
+
//#endregion
|
|
2112
|
+
//#region src/client-info.ts
|
|
2113
|
+
/**
|
|
2114
|
+
* Values for the `X-App-Version`, `X-Device-Region` and `X-Device-OS`
|
|
2115
|
+
* request headers. Each is omitted when unknown, so every helper here
|
|
2116
|
+
* returns `undefined` for a value the header can't carry as-is.
|
|
2117
|
+
*/
|
|
2118
|
+
/** Longest `X-App-Version` value sent (matches the core's limit). */
|
|
2119
|
+
const MAX_APP_VERSION_LENGTH = 64;
|
|
2120
|
+
/**
|
|
2121
|
+
* An app version for `X-App-Version`: the text before the first whitespace
|
|
2122
|
+
* (`"2.3.1 (45)"` sends `2.3.1`), which must start with an ASCII letter or
|
|
2123
|
+
* digit, hold only letters, digits and `.` `_` `+` `-`, and be at most
|
|
2124
|
+
* {@link MAX_APP_VERSION_LENGTH} characters: the shape the API records for
|
|
2125
|
+
* segment targeting. Anything else returns `undefined`.
|
|
2126
|
+
*/
|
|
2127
|
+
function normalizeAppVersion(raw) {
|
|
2128
|
+
if (typeof raw !== "string") return void 0;
|
|
2129
|
+
const v = raw.trim().split(/\s+/)[0] ?? "";
|
|
2130
|
+
if (v.length === 0 || v.length > MAX_APP_VERSION_LENGTH) return void 0;
|
|
2131
|
+
return /^[0-9A-Za-z][0-9A-Za-z._+-]*$/.test(v) ? v : void 0;
|
|
2132
|
+
}
|
|
2133
|
+
/**
|
|
2134
|
+
* The region subtag of a BCP 47 locale tag (`en-US` → `US`, `zh-Hant-TW` →
|
|
2135
|
+
* `TW`, POSIX `fr_FR.UTF-8` → `FR`, iOS `en_US@rg=gbzzzz` → `US`),
|
|
2136
|
+
* uppercased. Returns `undefined` when the tag carries no two-letter region
|
|
2137
|
+
* (`en`, `es-419`, `sr-Latn`).
|
|
2138
|
+
*/
|
|
2139
|
+
function regionFromLocale(tag) {
|
|
2140
|
+
if (typeof tag !== "string" || tag.length === 0) return void 0;
|
|
2141
|
+
const parts = tag.split(/[.@]/)[0].split(/[-_]/);
|
|
2142
|
+
for (let i = 1; i < parts.length; i++) {
|
|
2143
|
+
const part = parts[i];
|
|
2144
|
+
if (part.length === 1) break;
|
|
2145
|
+
if (/^[A-Za-z]{2}$/.test(part)) return part.toUpperCase();
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
const DEVICE_OS_VALUES = [
|
|
2149
|
+
"ios",
|
|
2150
|
+
"android",
|
|
2151
|
+
"web",
|
|
2152
|
+
"macos",
|
|
2153
|
+
"windows",
|
|
2154
|
+
"linux",
|
|
2155
|
+
"other"
|
|
2156
|
+
];
|
|
2157
|
+
/**
|
|
2158
|
+
* Map a platform OS name (React Native's `Platform.OS`, for example) to an
|
|
2159
|
+
* `X-Device-OS` value. Listed names pass through lowercased; any other
|
|
2160
|
+
* non-empty name is a known-but-unlisted OS and maps to `"other"`. Empty or
|
|
2161
|
+
* missing returns `undefined`.
|
|
2162
|
+
*/
|
|
2163
|
+
function deviceOsFromPlatform(os) {
|
|
2164
|
+
if (typeof os !== "string") return void 0;
|
|
2165
|
+
const v = os.trim().toLowerCase();
|
|
2166
|
+
if (v.length === 0) return void 0;
|
|
2167
|
+
return DEVICE_OS_VALUES.includes(v) ? v : "other";
|
|
2168
|
+
}
|
|
2169
|
+
|
|
1760
2170
|
//#endregion
|
|
1761
2171
|
//#region src/realtime.ts
|
|
1762
2172
|
const INITIAL_BACKOFF_MS = 500;
|
|
@@ -2080,7 +2490,7 @@ function delay(ms) {
|
|
|
2080
2490
|
//#endregion
|
|
2081
2491
|
//#region src/index.ts
|
|
2082
2492
|
/** SDK semver — burned in at build time. Bump alongside package.json. */
|
|
2083
|
-
const SDK_VERSION = "4.0
|
|
2493
|
+
const SDK_VERSION = "4.2.0";
|
|
2084
2494
|
/**
|
|
2085
2495
|
* Default host that serves deployed functions. `Amba.functions.invoke(name)`
|
|
2086
2496
|
* targets `https://<projectId>.<DEFAULT_FUNCTIONS_HOST>/<name>` unless a
|
|
@@ -2117,6 +2527,69 @@ function realtimeAuthHeaders() {
|
|
|
2117
2527
|
};
|
|
2118
2528
|
}
|
|
2119
2529
|
const authSubscribers = /* @__PURE__ */ new Set();
|
|
2530
|
+
/**
|
|
2531
|
+
* `Amba.onExperimentAssigned` listeners. `experiments.getVariant` reports
|
|
2532
|
+
* each result here; the reporter calls the listeners once per experiment per
|
|
2533
|
+
* user session. A sign-out (any auth change to no session) and `configure`
|
|
2534
|
+
* start a new session.
|
|
2535
|
+
*/
|
|
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
|
+
}
|
|
2120
2593
|
function snapshotSession(user) {
|
|
2121
2594
|
if (!user) return null;
|
|
2122
2595
|
return {
|
|
@@ -2127,6 +2600,7 @@ function snapshotSession(user) {
|
|
|
2127
2600
|
};
|
|
2128
2601
|
}
|
|
2129
2602
|
function notifyAuthSubscribers(session) {
|
|
2603
|
+
if (session === null) experimentReporter.reset();
|
|
2130
2604
|
for (const cb of authSubscribers) try {
|
|
2131
2605
|
cb(session);
|
|
2132
2606
|
} catch (err) {
|
|
@@ -2543,6 +3017,7 @@ const P = {
|
|
|
2543
3017
|
aiAnthropic: "/v1/client/ai/anthropic/messages",
|
|
2544
3018
|
config: "/v1/client/config",
|
|
2545
3019
|
flags: "/v1/client/flags",
|
|
3020
|
+
experiments: "/v1/client/experiments",
|
|
2546
3021
|
diagnosticsPing: "/v1/client/diagnostics/ping",
|
|
2547
3022
|
achievements: "/v1/client/achievements",
|
|
2548
3023
|
challenges: "/v1/client/challenges",
|
|
@@ -2686,6 +3161,39 @@ async function deleteDirect(s, name, id) {
|
|
|
2686
3161
|
return await s.http.delete(`${P.collections}/${encodePathSegment(name)}/${encodePathSegment(id)}`) ?? { data: { deleted: true } };
|
|
2687
3162
|
}
|
|
2688
3163
|
/**
|
|
3164
|
+
* The device OS for `X-Device-OS` from React Native's `Platform.OS`
|
|
3165
|
+
* (`ios`, `android`, `web` for react-native-web, `macos`, `windows`).
|
|
3166
|
+
* `undefined` outside React Native (Node, tests).
|
|
3167
|
+
*/
|
|
3168
|
+
function detectDeviceOs() {
|
|
3169
|
+
try {
|
|
3170
|
+
const os = require("react-native").Platform?.OS;
|
|
3171
|
+
return deviceOsFromPlatform(typeof os === "string" ? os : void 0);
|
|
3172
|
+
} catch {
|
|
3173
|
+
return;
|
|
3174
|
+
}
|
|
3175
|
+
}
|
|
3176
|
+
/**
|
|
3177
|
+
* The device locale's region for `X-Device-Region`: the iOS region setting
|
|
3178
|
+
* (`AppleLocale`) or Android's locale, falling back to the JS runtime's
|
|
3179
|
+
* default locale. `undefined` when none carries a two-letter region.
|
|
3180
|
+
*/
|
|
3181
|
+
function detectDeviceRegion() {
|
|
3182
|
+
try {
|
|
3183
|
+
const rn = require("react-native");
|
|
3184
|
+
const ios = (rn.NativeModules?.["SettingsManager"])?.settings;
|
|
3185
|
+
const android = rn.NativeModules?.["I18nManager"];
|
|
3186
|
+
const tag = ios?.AppleLocale ?? android?.localeIdentifier;
|
|
3187
|
+
const region = regionFromLocale(typeof tag === "string" ? tag : void 0);
|
|
3188
|
+
if (region) return region;
|
|
3189
|
+
} catch {}
|
|
3190
|
+
try {
|
|
3191
|
+
return regionFromLocale(Intl.DateTimeFormat().resolvedOptions().locale);
|
|
3192
|
+
} catch {
|
|
3193
|
+
return;
|
|
3194
|
+
}
|
|
3195
|
+
}
|
|
3196
|
+
/**
|
|
2689
3197
|
* Top-level SDK namespace for React Native.
|
|
2690
3198
|
*/
|
|
2691
3199
|
const Amba = {
|
|
@@ -2704,6 +3212,8 @@ const Amba = {
|
|
|
2704
3212
|
if (!TOKEN_STORAGE_MODES.includes(tokenStorage)) throw new Error("Amba.configure: tokenStorage must be \"secure\", \"memory\" or \"asyncStorage\"");
|
|
2705
3213
|
const secureStorage = config.secureStorage;
|
|
2706
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;
|
|
2707
3217
|
const storagePrefix = config.storagePrefix ?? "amba";
|
|
2708
3218
|
const persistence = new AsyncStoragePersistence(storagePrefix);
|
|
2709
3219
|
if (config.events?.batching) await persistence.assertDurable();
|
|
@@ -2711,12 +3221,17 @@ const Amba = {
|
|
|
2711
3221
|
const vault = await resolveTokenVault(tokenStorage, storagePrefix, secureStorage);
|
|
2712
3222
|
const identity = await IdentityStore.create(persistence, vault);
|
|
2713
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();
|
|
2714
3226
|
const sdkVersionHeader = `amba-sdk/${SDK_VERSION} ${`amba-react-native/${SDK_VERSION}`}`;
|
|
2715
3227
|
const http = new AmbaHttpClient({
|
|
2716
3228
|
apiKey: config.clientKey,
|
|
2717
3229
|
baseUrl,
|
|
2718
3230
|
sdkPlatform: "react-native",
|
|
2719
3231
|
sdkVersionHeader,
|
|
3232
|
+
appVersion: normalizeAppVersion(config.appVersion),
|
|
3233
|
+
deviceRegion: detectDeviceRegion(),
|
|
3234
|
+
deviceOs: detectDeviceOs(),
|
|
2720
3235
|
identity,
|
|
2721
3236
|
ensureSession: config.autoAnonymousSession === false ? void 0 : async (path) => {
|
|
2722
3237
|
if (pathSkipsLazySession(path)) return;
|
|
@@ -2729,6 +3244,7 @@ const Amba = {
|
|
|
2729
3244
|
if (prior?.offlineQueue) await prior.offlineQueue.dispose();
|
|
2730
3245
|
prior?.detachEventAppState?.();
|
|
2731
3246
|
if (prior?.eventQueue) await prior.eventQueue.dispose();
|
|
3247
|
+
experimentReporter.reset();
|
|
2732
3248
|
const nextState = {
|
|
2733
3249
|
http,
|
|
2734
3250
|
identity,
|
|
@@ -2740,6 +3256,11 @@ const Amba = {
|
|
|
2740
3256
|
resolvedFunctionsProjectId: null,
|
|
2741
3257
|
functionsProjectIdInflight: null,
|
|
2742
3258
|
persistence,
|
|
3259
|
+
remoteValues,
|
|
3260
|
+
refreshedFor: {
|
|
3261
|
+
config: null,
|
|
3262
|
+
flags: null
|
|
3263
|
+
},
|
|
2743
3264
|
offlineEnabled: false,
|
|
2744
3265
|
offlineQueue: null,
|
|
2745
3266
|
eventQueue: null
|
|
@@ -2990,7 +3511,10 @@ const Amba = {
|
|
|
2990
3511
|
} catch (err) {
|
|
2991
3512
|
if (typeof console !== "undefined") console.warn("[amba] signOut server call failed", err);
|
|
2992
3513
|
}
|
|
3514
|
+
const signedOutUser = s.identity.getAppUserId();
|
|
2993
3515
|
await s.identity.clearSession(rotateAnonymousId);
|
|
3516
|
+
s.remoteValues.clearForSignOut(signedOutUser);
|
|
3517
|
+
await s.remoteValues.flushed();
|
|
2994
3518
|
try {
|
|
2995
3519
|
await getOfflineQueue(s).purge();
|
|
2996
3520
|
} catch (err) {
|
|
@@ -3371,55 +3895,86 @@ const Amba = {
|
|
|
3371
3895
|
return text;
|
|
3372
3896
|
}
|
|
3373
3897
|
} },
|
|
3374
|
-
config: {
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
response
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
const
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
parsed =
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
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
|
+
},
|
|
3410
3938
|
flags: {
|
|
3411
3939
|
fetch: async () => {
|
|
3412
|
-
|
|
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);
|
|
3413
3944
|
},
|
|
3414
3945
|
get: async (key) => {
|
|
3946
|
+
const s = getState();
|
|
3947
|
+
const ticket = s.remoteValues.ticket();
|
|
3415
3948
|
try {
|
|
3416
|
-
|
|
3949
|
+
const env = await s.http.get(`${P.flags}/${encodePathSegment(key)}`);
|
|
3950
|
+
return s.remoteValues.upsertFlag(ticket, env.data);
|
|
3417
3951
|
} catch (err) {
|
|
3418
|
-
if (err instanceof AmbaApiError && err.code === "NOT_FOUND")
|
|
3952
|
+
if (err instanceof AmbaApiError && err.code === "NOT_FOUND") {
|
|
3953
|
+
s.remoteValues.removeFlag(ticket, key);
|
|
3954
|
+
return null;
|
|
3955
|
+
}
|
|
3419
3956
|
throw err;
|
|
3420
3957
|
}
|
|
3958
|
+
},
|
|
3959
|
+
getCached: flagsGetCached,
|
|
3960
|
+
isEnabled: flagsIsEnabled
|
|
3961
|
+
},
|
|
3962
|
+
experiments: {
|
|
3963
|
+
getVariant: async (key) => {
|
|
3964
|
+
const s = getState();
|
|
3965
|
+
const userBefore = s.identity.getAppUserId();
|
|
3966
|
+
const env = await s.http.get(`${P.experiments}/${encodePathSegment(key)}/assignment`);
|
|
3967
|
+
return experimentReporter.report({
|
|
3968
|
+
experimentKey: env.data.experiment_key,
|
|
3969
|
+
variant: env.data.variant,
|
|
3970
|
+
bucketingId: userBefore ?? s.identity.getAppUserId() ?? ""
|
|
3971
|
+
});
|
|
3972
|
+
},
|
|
3973
|
+
trackExposure: async (key) => {
|
|
3974
|
+
await getState().http.post(`${P.experiments}/${encodePathSegment(key)}/exposure`, {});
|
|
3421
3975
|
}
|
|
3422
3976
|
},
|
|
3977
|
+
onExperimentAssigned: (listener) => experimentReporter.subscribe(listener),
|
|
3423
3978
|
diagnostics: { ping: async () => {
|
|
3424
3979
|
return await getState().http.post(P.diagnosticsPing, {});
|
|
3425
3980
|
} },
|