@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.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,117 @@ 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/client-info.ts
1823
+ /**
1824
+ * Values for the `X-App-Version`, `X-Device-Region` and `X-Device-OS`
1825
+ * request headers. Each is omitted when unknown, so every helper here
1826
+ * returns `undefined` for a value the header can't carry as-is.
1827
+ */
1828
+ /** Longest `X-App-Version` value sent (matches the core's limit). */
1829
+ const MAX_APP_VERSION_LENGTH = 64;
1830
+ /**
1831
+ * An app version for `X-App-Version`: the text before the first whitespace
1832
+ * (`"2.3.1 (45)"` sends `2.3.1`), which must start with an ASCII letter or
1833
+ * digit, hold only letters, digits and `.` `_` `+` `-`, and be at most
1834
+ * {@link MAX_APP_VERSION_LENGTH} characters: the shape the API records for
1835
+ * segment targeting. Anything else returns `undefined`.
1836
+ */
1837
+ function normalizeAppVersion(raw) {
1838
+ if (typeof raw !== "string") return void 0;
1839
+ const v = raw.trim().split(/\s+/)[0] ?? "";
1840
+ if (v.length === 0 || v.length > MAX_APP_VERSION_LENGTH) return void 0;
1841
+ return /^[0-9A-Za-z][0-9A-Za-z._+-]*$/.test(v) ? v : void 0;
1842
+ }
1843
+ /**
1844
+ * The region subtag of a BCP 47 locale tag (`en-US` → `US`, `zh-Hant-TW` →
1845
+ * `TW`, POSIX `fr_FR.UTF-8` → `FR`, iOS `en_US@rg=gbzzzz` → `US`),
1846
+ * uppercased. Returns `undefined` when the tag carries no two-letter region
1847
+ * (`en`, `es-419`, `sr-Latn`).
1848
+ */
1849
+ function regionFromLocale(tag) {
1850
+ if (typeof tag !== "string" || tag.length === 0) return void 0;
1851
+ const parts = tag.split(/[.@]/)[0].split(/[-_]/);
1852
+ for (let i = 1; i < parts.length; i++) {
1853
+ const part = parts[i];
1854
+ if (part.length === 1) break;
1855
+ if (/^[A-Za-z]{2}$/.test(part)) return part.toUpperCase();
1856
+ }
1857
+ }
1858
+ const DEVICE_OS_VALUES = [
1859
+ "ios",
1860
+ "android",
1861
+ "web",
1862
+ "macos",
1863
+ "windows",
1864
+ "linux",
1865
+ "other"
1866
+ ];
1867
+ /**
1868
+ * Map a platform OS name (React Native's `Platform.OS`, for example) to an
1869
+ * `X-Device-OS` value. Listed names pass through lowercased; any other
1870
+ * non-empty name is a known-but-unlisted OS and maps to `"other"`. Empty or
1871
+ * missing returns `undefined`.
1872
+ */
1873
+ function deviceOsFromPlatform(os) {
1874
+ if (typeof os !== "string") return void 0;
1875
+ const v = os.trim().toLowerCase();
1876
+ if (v.length === 0) return void 0;
1877
+ return DEVICE_OS_VALUES.includes(v) ? v : "other";
1878
+ }
1879
+
1760
1880
  //#endregion
1761
1881
  //#region src/realtime.ts
1762
1882
  const INITIAL_BACKOFF_MS = 500;
@@ -2080,7 +2200,7 @@ function delay(ms) {
2080
2200
  //#endregion
2081
2201
  //#region src/index.ts
2082
2202
  /** SDK semver — burned in at build time. Bump alongside package.json. */
2083
- const SDK_VERSION = "4.0.14";
2203
+ const SDK_VERSION = "4.1.0";
2084
2204
  /**
2085
2205
  * Default host that serves deployed functions. `Amba.functions.invoke(name)`
2086
2206
  * targets `https://<projectId>.<DEFAULT_FUNCTIONS_HOST>/<name>` unless a
@@ -2117,6 +2237,13 @@ function realtimeAuthHeaders() {
2117
2237
  };
2118
2238
  }
2119
2239
  const authSubscribers = /* @__PURE__ */ new Set();
2240
+ /**
2241
+ * `Amba.onExperimentAssigned` listeners. `experiments.getVariant` reports
2242
+ * each result here; the reporter calls the listeners once per experiment per
2243
+ * user session. A sign-out (any auth change to no session) and `configure`
2244
+ * start a new session.
2245
+ */
2246
+ const experimentReporter = new ExperimentAssignmentReporter();
2120
2247
  function snapshotSession(user) {
2121
2248
  if (!user) return null;
2122
2249
  return {
@@ -2127,6 +2254,7 @@ function snapshotSession(user) {
2127
2254
  };
2128
2255
  }
2129
2256
  function notifyAuthSubscribers(session) {
2257
+ if (session === null) experimentReporter.reset();
2130
2258
  for (const cb of authSubscribers) try {
2131
2259
  cb(session);
2132
2260
  } catch (err) {
@@ -2543,6 +2671,7 @@ const P = {
2543
2671
  aiAnthropic: "/v1/client/ai/anthropic/messages",
2544
2672
  config: "/v1/client/config",
2545
2673
  flags: "/v1/client/flags",
2674
+ experiments: "/v1/client/experiments",
2546
2675
  diagnosticsPing: "/v1/client/diagnostics/ping",
2547
2676
  achievements: "/v1/client/achievements",
2548
2677
  challenges: "/v1/client/challenges",
@@ -2686,6 +2815,39 @@ async function deleteDirect(s, name, id) {
2686
2815
  return await s.http.delete(`${P.collections}/${encodePathSegment(name)}/${encodePathSegment(id)}`) ?? { data: { deleted: true } };
2687
2816
  }
2688
2817
  /**
2818
+ * The device OS for `X-Device-OS` from React Native's `Platform.OS`
2819
+ * (`ios`, `android`, `web` for react-native-web, `macos`, `windows`).
2820
+ * `undefined` outside React Native (Node, tests).
2821
+ */
2822
+ function detectDeviceOs() {
2823
+ try {
2824
+ const os = require("react-native").Platform?.OS;
2825
+ return deviceOsFromPlatform(typeof os === "string" ? os : void 0);
2826
+ } catch {
2827
+ return;
2828
+ }
2829
+ }
2830
+ /**
2831
+ * The device locale's region for `X-Device-Region`: the iOS region setting
2832
+ * (`AppleLocale`) or Android's locale, falling back to the JS runtime's
2833
+ * default locale. `undefined` when none carries a two-letter region.
2834
+ */
2835
+ function detectDeviceRegion() {
2836
+ try {
2837
+ const rn = require("react-native");
2838
+ const ios = (rn.NativeModules?.["SettingsManager"])?.settings;
2839
+ const android = rn.NativeModules?.["I18nManager"];
2840
+ const tag = ios?.AppleLocale ?? android?.localeIdentifier;
2841
+ const region = regionFromLocale(typeof tag === "string" ? tag : void 0);
2842
+ if (region) return region;
2843
+ } catch {}
2844
+ try {
2845
+ return regionFromLocale(Intl.DateTimeFormat().resolvedOptions().locale);
2846
+ } catch {
2847
+ return;
2848
+ }
2849
+ }
2850
+ /**
2689
2851
  * Top-level SDK namespace for React Native.
2690
2852
  */
2691
2853
  const Amba = {
@@ -2717,6 +2879,9 @@ const Amba = {
2717
2879
  baseUrl,
2718
2880
  sdkPlatform: "react-native",
2719
2881
  sdkVersionHeader,
2882
+ appVersion: normalizeAppVersion(config.appVersion),
2883
+ deviceRegion: detectDeviceRegion(),
2884
+ deviceOs: detectDeviceOs(),
2720
2885
  identity,
2721
2886
  ensureSession: config.autoAnonymousSession === false ? void 0 : async (path) => {
2722
2887
  if (pathSkipsLazySession(path)) return;
@@ -2729,6 +2894,7 @@ const Amba = {
2729
2894
  if (prior?.offlineQueue) await prior.offlineQueue.dispose();
2730
2895
  prior?.detachEventAppState?.();
2731
2896
  if (prior?.eventQueue) await prior.eventQueue.dispose();
2897
+ experimentReporter.reset();
2732
2898
  const nextState = {
2733
2899
  http,
2734
2900
  identity,
@@ -3420,6 +3586,22 @@ const Amba = {
3420
3586
  }
3421
3587
  }
3422
3588
  },
3589
+ experiments: {
3590
+ getVariant: async (key) => {
3591
+ const s = getState();
3592
+ const userBefore = s.identity.getAppUserId();
3593
+ const env = await s.http.get(`${P.experiments}/${encodePathSegment(key)}/assignment`);
3594
+ return experimentReporter.report({
3595
+ experimentKey: env.data.experiment_key,
3596
+ variant: env.data.variant,
3597
+ bucketingId: userBefore ?? s.identity.getAppUserId() ?? ""
3598
+ });
3599
+ },
3600
+ trackExposure: async (key) => {
3601
+ await getState().http.post(`${P.experiments}/${encodePathSegment(key)}/exposure`, {});
3602
+ }
3603
+ },
3604
+ onExperimentAssigned: (listener) => experimentReporter.subscribe(listener),
3423
3605
  diagnostics: { ping: async () => {
3424
3606
  return await getState().http.post(P.diagnosticsPing, {});
3425
3607
  } },