@layers/amba-react-native 4.0.14 → 4.1.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,117 @@ 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/client-info.ts
1829
+ /**
1830
+ * Values for the `X-App-Version`, `X-Device-Region` and `X-Device-OS`
1831
+ * request headers. Each is omitted when unknown, so every helper here
1832
+ * returns `undefined` for a value the header can't carry as-is.
1833
+ */
1834
+ /** Longest `X-App-Version` value sent (matches the core's limit). */
1835
+ const MAX_APP_VERSION_LENGTH = 64;
1836
+ /**
1837
+ * An app version for `X-App-Version`: the text before the first whitespace
1838
+ * (`"2.3.1 (45)"` sends `2.3.1`), which must start with an ASCII letter or
1839
+ * digit, hold only letters, digits and `.` `_` `+` `-`, and be at most
1840
+ * {@link MAX_APP_VERSION_LENGTH} characters: the shape the API records for
1841
+ * segment targeting. Anything else returns `undefined`.
1842
+ */
1843
+ function normalizeAppVersion(raw) {
1844
+ if (typeof raw !== "string") return void 0;
1845
+ const v = raw.trim().split(/\s+/)[0] ?? "";
1846
+ if (v.length === 0 || v.length > MAX_APP_VERSION_LENGTH) return void 0;
1847
+ return /^[0-9A-Za-z][0-9A-Za-z._+-]*$/.test(v) ? v : void 0;
1848
+ }
1849
+ /**
1850
+ * The region subtag of a BCP 47 locale tag (`en-US` → `US`, `zh-Hant-TW` →
1851
+ * `TW`, POSIX `fr_FR.UTF-8` → `FR`, iOS `en_US@rg=gbzzzz` → `US`),
1852
+ * uppercased. Returns `undefined` when the tag carries no two-letter region
1853
+ * (`en`, `es-419`, `sr-Latn`).
1854
+ */
1855
+ function regionFromLocale(tag) {
1856
+ if (typeof tag !== "string" || tag.length === 0) return void 0;
1857
+ const parts = tag.split(/[.@]/)[0].split(/[-_]/);
1858
+ for (let i = 1; i < parts.length; i++) {
1859
+ const part = parts[i];
1860
+ if (part.length === 1) break;
1861
+ if (/^[A-Za-z]{2}$/.test(part)) return part.toUpperCase();
1862
+ }
1863
+ }
1864
+ const DEVICE_OS_VALUES = [
1865
+ "ios",
1866
+ "android",
1867
+ "web",
1868
+ "macos",
1869
+ "windows",
1870
+ "linux",
1871
+ "other"
1872
+ ];
1873
+ /**
1874
+ * Map a platform OS name (React Native's `Platform.OS`, for example) to an
1875
+ * `X-Device-OS` value. Listed names pass through lowercased; any other
1876
+ * non-empty name is a known-but-unlisted OS and maps to `"other"`. Empty or
1877
+ * missing returns `undefined`.
1878
+ */
1879
+ function deviceOsFromPlatform(os) {
1880
+ if (typeof os !== "string") return void 0;
1881
+ const v = os.trim().toLowerCase();
1882
+ if (v.length === 0) return void 0;
1883
+ return DEVICE_OS_VALUES.includes(v) ? v : "other";
1884
+ }
1885
+
1766
1886
  //#endregion
1767
1887
  //#region src/realtime.ts
1768
1888
  const INITIAL_BACKOFF_MS = 500;
@@ -2086,7 +2206,7 @@ function delay(ms) {
2086
2206
  //#endregion
2087
2207
  //#region src/index.ts
2088
2208
  /** SDK semver — burned in at build time. Bump alongside package.json. */
2089
- const SDK_VERSION = "4.0.14";
2209
+ const SDK_VERSION = "4.1.0";
2090
2210
  /**
2091
2211
  * Default host that serves deployed functions. `Amba.functions.invoke(name)`
2092
2212
  * targets `https://<projectId>.<DEFAULT_FUNCTIONS_HOST>/<name>` unless a
@@ -2123,6 +2243,13 @@ function realtimeAuthHeaders() {
2123
2243
  };
2124
2244
  }
2125
2245
  const authSubscribers = /* @__PURE__ */ new Set();
2246
+ /**
2247
+ * `Amba.onExperimentAssigned` listeners. `experiments.getVariant` reports
2248
+ * each result here; the reporter calls the listeners once per experiment per
2249
+ * user session. A sign-out (any auth change to no session) and `configure`
2250
+ * start a new session.
2251
+ */
2252
+ const experimentReporter = new ExperimentAssignmentReporter();
2126
2253
  function snapshotSession(user) {
2127
2254
  if (!user) return null;
2128
2255
  return {
@@ -2133,6 +2260,7 @@ function snapshotSession(user) {
2133
2260
  };
2134
2261
  }
2135
2262
  function notifyAuthSubscribers(session) {
2263
+ if (session === null) experimentReporter.reset();
2136
2264
  for (const cb of authSubscribers) try {
2137
2265
  cb(session);
2138
2266
  } catch (err) {
@@ -2549,6 +2677,7 @@ const P = {
2549
2677
  aiAnthropic: "/v1/client/ai/anthropic/messages",
2550
2678
  config: "/v1/client/config",
2551
2679
  flags: "/v1/client/flags",
2680
+ experiments: "/v1/client/experiments",
2552
2681
  diagnosticsPing: "/v1/client/diagnostics/ping",
2553
2682
  achievements: "/v1/client/achievements",
2554
2683
  challenges: "/v1/client/challenges",
@@ -2692,6 +2821,39 @@ async function deleteDirect(s, name, id) {
2692
2821
  return await s.http.delete(`${P.collections}/${encodePathSegment(name)}/${encodePathSegment(id)}`) ?? { data: { deleted: true } };
2693
2822
  }
2694
2823
  /**
2824
+ * The device OS for `X-Device-OS` from React Native's `Platform.OS`
2825
+ * (`ios`, `android`, `web` for react-native-web, `macos`, `windows`).
2826
+ * `undefined` outside React Native (Node, tests).
2827
+ */
2828
+ function detectDeviceOs() {
2829
+ try {
2830
+ const os = require("react-native").Platform?.OS;
2831
+ return deviceOsFromPlatform(typeof os === "string" ? os : void 0);
2832
+ } catch {
2833
+ return;
2834
+ }
2835
+ }
2836
+ /**
2837
+ * The device locale's region for `X-Device-Region`: the iOS region setting
2838
+ * (`AppleLocale`) or Android's locale, falling back to the JS runtime's
2839
+ * default locale. `undefined` when none carries a two-letter region.
2840
+ */
2841
+ function detectDeviceRegion() {
2842
+ try {
2843
+ const rn = require("react-native");
2844
+ const ios = (rn.NativeModules?.["SettingsManager"])?.settings;
2845
+ const android = rn.NativeModules?.["I18nManager"];
2846
+ const tag = ios?.AppleLocale ?? android?.localeIdentifier;
2847
+ const region = regionFromLocale(typeof tag === "string" ? tag : void 0);
2848
+ if (region) return region;
2849
+ } catch {}
2850
+ try {
2851
+ return regionFromLocale(Intl.DateTimeFormat().resolvedOptions().locale);
2852
+ } catch {
2853
+ return;
2854
+ }
2855
+ }
2856
+ /**
2695
2857
  * Top-level SDK namespace for React Native.
2696
2858
  */
2697
2859
  const Amba = {
@@ -2723,6 +2885,9 @@ const Amba = {
2723
2885
  baseUrl,
2724
2886
  sdkPlatform: "react-native",
2725
2887
  sdkVersionHeader,
2888
+ appVersion: normalizeAppVersion(config.appVersion),
2889
+ deviceRegion: detectDeviceRegion(),
2890
+ deviceOs: detectDeviceOs(),
2726
2891
  identity,
2727
2892
  ensureSession: config.autoAnonymousSession === false ? void 0 : async (path) => {
2728
2893
  if (pathSkipsLazySession(path)) return;
@@ -2735,6 +2900,7 @@ const Amba = {
2735
2900
  if (prior?.offlineQueue) await prior.offlineQueue.dispose();
2736
2901
  prior?.detachEventAppState?.();
2737
2902
  if (prior?.eventQueue) await prior.eventQueue.dispose();
2903
+ experimentReporter.reset();
2738
2904
  const nextState = {
2739
2905
  http,
2740
2906
  identity,
@@ -3426,6 +3592,22 @@ const Amba = {
3426
3592
  }
3427
3593
  }
3428
3594
  },
3595
+ experiments: {
3596
+ getVariant: async (key) => {
3597
+ const s = getState();
3598
+ const userBefore = s.identity.getAppUserId();
3599
+ const env = await s.http.get(`${P.experiments}/${encodePathSegment(key)}/assignment`);
3600
+ return experimentReporter.report({
3601
+ experimentKey: env.data.experiment_key,
3602
+ variant: env.data.variant,
3603
+ bucketingId: userBefore ?? s.identity.getAppUserId() ?? ""
3604
+ });
3605
+ },
3606
+ trackExposure: async (key) => {
3607
+ await getState().http.post(`${P.experiments}/${encodePathSegment(key)}/exposure`, {});
3608
+ }
3609
+ },
3610
+ onExperimentAssigned: (listener) => experimentReporter.subscribe(listener),
3429
3611
  diagnostics: { ping: async () => {
3430
3612
  return await getState().http.post(P.diagnosticsPing, {});
3431
3613
  } },