@layers/amba-react-native 4.1.0 → 4.2.1

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
@@ -84,6 +84,167 @@ var AmbaApiError = class extends Error {
84
84
  }
85
85
  };
86
86
 
87
+ //#endregion
88
+ //#region src/request-deadline.ts
89
+ /**
90
+ * Request deadlines, shared by every JS SDK target (the same file ships in
91
+ * `@layers/amba-web`, `@layers/amba-node` and `@layers/amba-react-native`).
92
+ *
93
+ * Every request the SDK sends runs under a deadline that covers sending it
94
+ * and reading the whole response. When the deadline passes, the request is
95
+ * aborted and fails with the SDK's retryable network error, so one request
96
+ * can never hold an in-flight slot (the offline outbox flush, the lazy
97
+ * anonymous sign-in, the session refresh, the event batch flush) for the
98
+ * life of the process. Some runtimes never settle a fetch they gave up on:
99
+ * Expo on Android drops a failed DNS lookup and the promise stays pending.
100
+ * The deadline wins the race even then, because it rejects on its own timer
101
+ * and then aborts the fetch for the runtime to clean up.
102
+ *
103
+ * Two bounds:
104
+ * - `requestTimeoutMs` (default 15 s): an ordinary API request.
105
+ * - `longRequestTimeoutMs` (default 5 min): work that legitimately runs
106
+ * longer. AI generation (`/v1/client/ai/*`), function invocations, and
107
+ * media uploads and downloads.
108
+ * Both grow with the bytes a request moves (see `withTransferAllowance`), so
109
+ * a large upload, download or event batch on a slow link has time to finish.
110
+ * Realtime subscriptions are bounded until their stream opens; AI response
111
+ * streams likewise until the response starts. Once open, a stream runs for
112
+ * as long as the caller keeps it.
113
+ */
114
+ /** Default deadline for one API request, in milliseconds. */
115
+ const DEFAULT_REQUEST_TIMEOUT_MS = 15e3;
116
+ /**
117
+ * Default deadline for AI generation, function invocations and media
118
+ * transfers, in milliseconds.
119
+ */
120
+ const DEFAULT_LONG_REQUEST_TIMEOUT_MS = 3e5;
121
+ /**
122
+ * The slowest link a deadline makes room for, in bytes per second
123
+ * (8 KiB/s, about 64 kbit/s). Each byte a request sends or receives beyond
124
+ * the first {@link TRANSFER_BYTES_WITHIN_BASE} adds the time it takes at this
125
+ * rate to the deadline.
126
+ */
127
+ const MIN_TRANSFER_BYTES_PER_SECOND = 8 * 1024;
128
+ /**
129
+ * Bytes a request moves within its base deadline (64 KiB: 8 s at the
130
+ * slowest rate). An ordinary API request stays at exactly
131
+ * `requestTimeoutMs`; only larger bodies widen it.
132
+ */
133
+ const TRANSFER_BYTES_WITHIN_BASE = 64 * 1024;
134
+ /** The largest delay `setTimeout` honors (about 24.8 days). */
135
+ const MAX_TIMEOUT_MS = 2147483647;
136
+ /**
137
+ * Read the deadlines from the configure options, applying the defaults.
138
+ * Throws an `Error` naming the option when a value is not a positive number
139
+ * of milliseconds.
140
+ */
141
+ function resolveRequestDeadlines(options) {
142
+ return {
143
+ requestMs: readTimeout("Amba.configure: requestTimeoutMs", options.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS),
144
+ longMs: readTimeout("Amba.configure: longRequestTimeoutMs", options.longRequestTimeoutMs, DEFAULT_LONG_REQUEST_TIMEOUT_MS)
145
+ };
146
+ }
147
+ /**
148
+ * Validate one timeout value; `undefined` keeps `fallback`. `label` names the
149
+ * option in the thrown `Error` (`Amba.configure: requestTimeoutMs`).
150
+ */
151
+ function readTimeout(label, value, fallback) {
152
+ if (value === void 0) return fallback;
153
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0 || value > MAX_TIMEOUT_MS) throw new Error(`${label} must be a positive number of milliseconds, at most ${MAX_TIMEOUT_MS}`);
154
+ return value;
155
+ }
156
+ /** The text a deadline failure carries, e.g. `request timed out after 15000 ms`. */
157
+ function timeoutMessage(timeoutMs) {
158
+ return `request timed out after ${timeoutMs} ms`;
159
+ }
160
+ /**
161
+ * `timeoutMs` plus the time the bytes beyond the first
162
+ * {@link TRANSFER_BYTES_WITHIN_BASE} take at
163
+ * {@link MIN_TRANSFER_BYTES_PER_SECOND}, capped at the largest delay
164
+ * `setTimeout` honors. Unknown sizes add nothing.
165
+ */
166
+ function withTransferAllowance(timeoutMs, bytes) {
167
+ const beyond = typeof bytes === "number" && Number.isFinite(bytes) ? bytes - TRANSFER_BYTES_WITHIN_BASE : 0;
168
+ const extra = beyond > 0 ? Math.ceil(beyond / MIN_TRANSFER_BYTES_PER_SECOND * 1e3) : 0;
169
+ return Math.min(MAX_TIMEOUT_MS, timeoutMs + extra);
170
+ }
171
+ /**
172
+ * The size of a request body in bytes: a string's UTF-8 length, a buffer's
173
+ * byte length, a `Blob`'s size. `undefined` for no body or a body of unknown
174
+ * size (a stream).
175
+ */
176
+ function bodyByteLength(body) {
177
+ if (body === void 0 || body === null) return void 0;
178
+ if (typeof body === "string") return typeof TextEncoder !== "undefined" ? new TextEncoder().encode(body).length : body.length;
179
+ if (typeof ArrayBuffer !== "undefined") {
180
+ if (body instanceof ArrayBuffer) return body.byteLength;
181
+ if (ArrayBuffer.isView(body)) return body.byteLength;
182
+ }
183
+ const size = body.size;
184
+ return typeof size === "number" ? size : void 0;
185
+ }
186
+ /** A response's `Content-Length`, when it states one. */
187
+ function contentLength(response) {
188
+ const raw = response.headers?.get?.("content-length");
189
+ if (!raw) return void 0;
190
+ const n = Number(raw);
191
+ return Number.isFinite(n) && n >= 0 ? n : void 0;
192
+ }
193
+ /** The paths under the API base URL whose requests get the long deadline. */
194
+ const LONG_API_PATH_PREFIXES = ["/v1/client/ai/"];
195
+ /** Whether a request to API `path` (starting `/v1/...`) gets the long deadline. */
196
+ function isLongApiPath(path) {
197
+ return LONG_API_PATH_PREFIXES.some((prefix) => path.startsWith(prefix));
198
+ }
199
+ /**
200
+ * Race `work` against a timer of `ms`. When the timer fires first the race
201
+ * rejects with `onTimeout(ms)`, then `controller` is aborted so the runtime
202
+ * can release the connection. The rejection does not wait for `work`, so a
203
+ * fetch the runtime never settles still releases its caller on time. The
204
+ * timer is cleared as soon as either side settles.
205
+ */
206
+ function raceDeadline(work, ms, controller, onTimeout) {
207
+ let timer;
208
+ const deadline = new Promise((_, reject) => {
209
+ timer = setTimeout(() => {
210
+ reject(onTimeout(ms));
211
+ try {
212
+ controller?.abort();
213
+ } catch {}
214
+ }, ms);
215
+ });
216
+ return Promise.race([work, deadline]).finally(() => {
217
+ clearTimeout(timer);
218
+ });
219
+ }
220
+ function newController() {
221
+ return typeof AbortController === "function" ? new AbortController() : void 0;
222
+ }
223
+ /**
224
+ * Send a request and read its response under deadlines that grow with the
225
+ * bytes involved. Sending, until `send` resolves with the response, gets
226
+ * `timeoutMs` plus the allowance for the request body (and, with
227
+ * `fetchBuffersBody`, for the expected response bytes). Reading,
228
+ * `read(response)`, gets `timeoutMs` plus the allowance for the expected
229
+ * response bytes, else the response's `Content-Length`. One `AbortSignal`
230
+ * covers both phases, so a read past its deadline aborts the transfer too.
231
+ * Past either deadline the promise rejects with `onTimeout(ms)`.
232
+ */
233
+ async function exchange(timeoutMs, send, read, onTimeout, sizes = {}) {
234
+ const controller = newController();
235
+ const sendMs = withTransferAllowance(timeoutMs, (sizes.requestBytes ?? 0) + (sizes.fetchBuffersBody ? sizes.responseBytes ?? 0 : 0));
236
+ const response = await raceDeadline(send(controller?.signal), sendMs, controller, onTimeout);
237
+ const readMs = withTransferAllowance(timeoutMs, sizes.responseBytes ?? contentLength(response));
238
+ return raceDeadline(read(response), readMs, controller, onTimeout);
239
+ }
240
+ /** `init` with `signal` set, when there is one. */
241
+ function withSignal(init, signal) {
242
+ return signal ? {
243
+ ...init,
244
+ signal
245
+ } : init;
246
+ }
247
+
87
248
  //#endregion
88
249
  //#region src/http-client.ts
89
250
  /** Default amba API endpoint. */
@@ -100,6 +261,12 @@ var AmbaHttpClient = class {
100
261
  deviceOs;
101
262
  identity;
102
263
  fetchImpl;
264
+ /** The `fetchImpl` option, when one was passed (`send` resolves fetch per call otherwise). */
265
+ injectedFetch;
266
+ /** Deadline for an API request, in milliseconds. */
267
+ requestTimeoutMs;
268
+ /** Deadline for AI generation, function invocations and media transfers. */
269
+ longRequestTimeoutMs;
103
270
  ensureSession;
104
271
  onSessionRefused;
105
272
  constructor(opts) {
@@ -113,7 +280,39 @@ var AmbaHttpClient = class {
113
280
  this.identity = opts.identity;
114
281
  this.ensureSession = opts.ensureSession;
115
282
  this.onSessionRefused = opts.onSessionRefused;
283
+ this.injectedFetch = opts.fetchImpl;
116
284
  this.fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
285
+ this.requestTimeoutMs = opts.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
286
+ this.longRequestTimeoutMs = opts.longRequestTimeoutMs ?? DEFAULT_LONG_REQUEST_TIMEOUT_MS;
287
+ }
288
+ /**
289
+ * Send a request to an absolute URL outside the API's JSON envelope (a
290
+ * function invocation, a CDN download, the config route that reads the
291
+ * `ETag`) under a deadline. `read` takes what the caller needs from the
292
+ * response (the body as text, a `Blob`) and runs inside the same
293
+ * deadline, so a body that stops arriving fails too.
294
+ *
295
+ * A transport failure rejects with `NETWORK_ERROR` and the message
296
+ * `<label>: <cause>`; the deadline rejects with `NETWORK_ERROR` and
297
+ * `<label>: request timed out after <ms> ms`. `label` defaults to
298
+ * `Network error` and `timeoutMs` to the request deadline; the deadline
299
+ * widens with the request body and with `responseBytes`, the size the
300
+ * caller expects to download (see `exchange`). An `AmbaApiError` that
301
+ * `read` throws passes through unchanged.
302
+ */
303
+ async send(url, init, read, opts = {}) {
304
+ const timeoutMs = opts.timeoutMs ?? this.requestTimeoutMs;
305
+ const label = opts.label ?? "Network error";
306
+ const fetchFn = this.injectedFetch ?? globalThis.fetch;
307
+ try {
308
+ return await exchange(timeoutMs, (signal) => fetchFn(url, withSignal(init, signal)), read, (ms) => timeoutError(label, ms), {
309
+ requestBytes: bodyByteLength(init.body),
310
+ responseBytes: opts.responseBytes,
311
+ fetchBuffersBody: true
312
+ });
313
+ } catch (err) {
314
+ throw asNetworkError(err, label);
315
+ }
117
316
  }
118
317
  /** Build the absolute URL for an API path, optionally with a query string. */
119
318
  url(path, query) {
@@ -173,14 +372,19 @@ var AmbaHttpClient = class {
173
372
  headers,
174
373
  body: body === void 0 ? void 0 : JSON.stringify(body ?? {})
175
374
  };
375
+ const timeoutMs = isLongApiPath(path) ? this.longRequestTimeoutMs : this.requestTimeoutMs;
176
376
  let response;
377
+ let text;
177
378
  try {
178
- response = await this.fetchImpl(url, init);
379
+ ({response, text} = await exchange(timeoutMs, (signal) => this.fetchImpl(url, withSignal(init, signal)), async (res) => ({
380
+ response: res,
381
+ text: res.status === 204 ? "" : await res.text()
382
+ }), (ms) => timeoutError("Network error", ms), { requestBytes: bodyByteLength(init.body) }));
179
383
  } catch (err) {
180
- throw new AmbaApiError("NETWORK_ERROR", `Network error: ${err instanceof Error ? err.message : typeof err === "string" ? err : "fetch failed"}`, err);
384
+ throw asNetworkError(err, "Network error");
181
385
  }
182
386
  try {
183
- return await parseResponse(response);
387
+ return parseResponse(response, text);
184
388
  } catch (err) {
185
389
  if (!resent && storedToken && this.onSessionRefused && !path.startsWith("/v1/client/auth/") && err instanceof AmbaApiError && SESSION_REFUSED_CODES.has(err.code) && await this.recoverSession(storedToken, storedUserId)) return this.request(method, path, body, query, void 0, true);
186
390
  throw err;
@@ -195,9 +399,21 @@ var AmbaHttpClient = class {
195
399
  }
196
400
  }
197
401
  };
198
- async function parseResponse(response) {
402
+ /** The `NETWORK_ERROR` a passed deadline raises. */
403
+ function timeoutError(label, timeoutMs) {
404
+ return new AmbaApiError("NETWORK_ERROR", `${label}: ${timeoutMessage(timeoutMs)}`, { timeoutMs });
405
+ }
406
+ /**
407
+ * Wrap a transport failure as `NETWORK_ERROR` (`<label>: <cause>`). An
408
+ * `AmbaApiError` (the deadline's own, or one a `send` reader throws) passes
409
+ * through unchanged.
410
+ */
411
+ function asNetworkError(err, label) {
412
+ if (err instanceof AmbaApiError) return err;
413
+ return new AmbaApiError("NETWORK_ERROR", `${label}: ${err instanceof Error ? err.message : typeof err === "string" ? err : "fetch failed"}`, err);
414
+ }
415
+ function parseResponse(response, text) {
199
416
  if (response.status === 204) return null;
200
- const text = await response.text();
201
417
  const isJson = (response.headers.get("content-type") ?? "").includes("application/json");
202
418
  if (!response.ok) throw mapHttpError(response, text, isJson);
203
419
  if (text === "") return null;
@@ -893,7 +1109,16 @@ const DETERMINISTIC_ERROR_CODES = new Set([
893
1109
  "NOT_FOUND",
894
1110
  "CONFLICT",
895
1111
  "CONSENT_NOT_GRANTED",
896
- "NOT_INITIALIZED"
1112
+ "NOT_INITIALIZED",
1113
+ "INVALID_JSON",
1114
+ "INVALID_BODY",
1115
+ "INVALID_EVENT",
1116
+ "RESERVED_EVENT_NAME",
1117
+ "INVALID_IDEMPOTENCY_KEY",
1118
+ "INVALID_TIMESTAMP",
1119
+ "INVALID_PROPERTIES",
1120
+ "INVALID_TELEMETRY",
1121
+ "IDEMPOTENCY_KEY_CONFLICT"
897
1122
  ]);
898
1123
  /**
899
1124
  * Is this a deterministic server rejection — a coded error the server would
@@ -931,13 +1156,34 @@ function isOfflineError(err) {
931
1156
  if (typeof msg === "string" && /network (error|request failed)|failed to fetch|fetch failed|load failed/i.test(msg)) return true;
932
1157
  return !isOnline();
933
1158
  }
934
- /** Generate a stable idempotency key for a queued op. */
1159
+ /**
1160
+ * Generate a stable idempotency key for a queued op: `amba_obx_` and a random
1161
+ * UUID. The server records event keys project-wide, so the UUID comes from
1162
+ * the runtime's secure random source: `crypto.randomUUID`, else
1163
+ * `crypto.getRandomValues` (Hermes has only the latter).
1164
+ */
935
1165
  function newIdempotencyKey() {
936
1166
  const c = typeof globalThis !== "undefined" ? globalThis.crypto : void 0;
937
1167
  if (c && typeof c.randomUUID === "function") return `amba_obx_${c.randomUUID()}`;
1168
+ if (c && typeof c.getRandomValues === "function") {
1169
+ const b = c.getRandomValues(new Uint8Array(16));
1170
+ b[6] = b[6] & 15 | 64;
1171
+ b[8] = b[8] & 63 | 128;
1172
+ const h = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
1173
+ return `amba_obx_${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
1174
+ }
938
1175
  return `amba_obx_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`;
939
1176
  }
940
1177
  /**
1178
+ * Whether a `track` with these properties can take the events route's keyed
1179
+ * path, which accepts a plain object or no properties. A JS caller can pass
1180
+ * something else (an array); such an event stays on the unkeyed path, which
1181
+ * stores it as it always has.
1182
+ */
1183
+ function keyableProperties(properties) {
1184
+ return properties === void 0 || properties === null || typeof properties === "object" && !Array.isArray(properties);
1185
+ }
1186
+ /**
941
1187
  * Durable, ordered, idempotent offline outbox. Opt-in via `Amba.offline`.
942
1188
  * Single in-flight flush is enforced so concurrent reconnect signals don't
943
1189
  * double-replay.
@@ -1818,6 +2064,296 @@ function logListenerError(err) {
1818
2064
  console.warn("[amba] onExperimentAssigned listener threw", err);
1819
2065
  }
1820
2066
 
2067
+ //#endregion
2068
+ //#region src/remote-values.ts
2069
+ /** Prefix of the per-project AsyncStorage record key. */
2070
+ const CACHE_KEY_PREFIX = "amba_rc_v1_";
2071
+ /** Slot for values evaluated without a signed-in user. */
2072
+ const ANON_SLOT = "anon";
2073
+ /** Most user slots one project record keeps. */
2074
+ const MAX_SLOTS = 5;
2075
+ const TWO_32 = 4294967296;
2076
+ /**
2077
+ * 64-bit FNV-1a over the string's UTF-16 code units (low byte, then high
2078
+ * byte), as 16 hex digits. Computed in two 32-bit halves: some React Native
2079
+ * engines (JavaScriptCore on older Android builds) have no BigInt.
2080
+ */
2081
+ function fnv1a64Hex(text) {
2082
+ let hi = 3421674724;
2083
+ let lo = 2216829733;
2084
+ const step = (byte) => {
2085
+ lo = (lo ^ byte) >>> 0;
2086
+ const low = lo * 435;
2087
+ const carry = Math.floor(low / TWO_32);
2088
+ const high = hi * 435 + carry + (lo << 8 >>> 0);
2089
+ lo = low % TWO_32;
2090
+ hi = high % TWO_32;
2091
+ };
2092
+ for (let i = 0; i < text.length; i++) {
2093
+ const unit = text.charCodeAt(i);
2094
+ step(unit & 255);
2095
+ step(unit >>> 8);
2096
+ }
2097
+ return hi.toString(16).padStart(8, "0") + lo.toString(16).padStart(8, "0");
2098
+ }
2099
+ /** The record key for a project: a hash of the base URL and client key. */
2100
+ function recordKey(baseUrl, clientKey) {
2101
+ return `${CACHE_KEY_PREFIX}${fnv1a64Hex(`${baseUrl.replace(/\/+$/, "")}\n${clientKey}`)}`;
2102
+ }
2103
+ /** The slot key for an identity: `u` + a hash of the user id, or `anon`. */
2104
+ function slotKey(appUserId) {
2105
+ return appUserId ? `u${fnv1a64Hex(appUserId)}` : ANON_SLOT;
2106
+ }
2107
+ /**
2108
+ * The user a config response was evaluated for, from its `ETag`:
2109
+ * `"<version>:<app user id>:<segments>"` for a signed-in caller, `"<version>"`
2110
+ * for every other one (no session, or an expired or revoked token, which the
2111
+ * server answers with each key's default value). `undefined` without an
2112
+ * `ETag`; `null` for an evaluation for nobody.
2113
+ */
2114
+ function etagEvaluatedFor(etag) {
2115
+ if (!etag) return void 0;
2116
+ const parts = etag.trim().replace(/^W\//, "").replace(/^"|"$/g, "").split(":");
2117
+ return parts.length > 1 && parts[1] ? parts[1] : null;
2118
+ }
2119
+ function isPlainObject(value) {
2120
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2121
+ }
2122
+ function validSlot(raw) {
2123
+ if (!isPlainObject(raw)) return null;
2124
+ const slot = { saved_at: typeof raw["saved_at"] === "number" ? raw["saved_at"] : 0 };
2125
+ const config = raw["config"];
2126
+ if (isPlainObject(config) && isPlainObject(config["values"])) slot.config = {
2127
+ version: typeof config["version"] === "string" ? config["version"] : null,
2128
+ values: config["values"]
2129
+ };
2130
+ const flags = raw["flags"];
2131
+ if (Array.isArray(flags)) slot.flags = flags.filter((f) => isPlainObject(f) && typeof f["name"] === "string" && typeof f["enabled"] === "boolean");
2132
+ return slot.config || slot.flags ? slot : null;
2133
+ }
2134
+ function upsert(list, flag) {
2135
+ const i = list.findIndex((f) => f.name === flag.name);
2136
+ if (i >= 0) list[i] = flag;
2137
+ else list.push(flag);
2138
+ }
2139
+ var RemoteValuesCache = class {
2140
+ record = {
2141
+ v: 1,
2142
+ slots: {}
2143
+ };
2144
+ /** The slot key the held values were resolved for. */
2145
+ viewFor = null;
2146
+ config = null;
2147
+ flags = null;
2148
+ /** Bumped by every sign-out; an older ticket's response is not cached. */
2149
+ generation = 0;
2150
+ writes = Promise.resolve();
2151
+ constructor(store, key, defaults, currentUser) {
2152
+ this.store = store;
2153
+ this.key = key;
2154
+ this.defaults = defaults;
2155
+ this.currentUser = currentUser;
2156
+ }
2157
+ /** Read the project's record from storage. `configure()` awaits it. */
2158
+ async load() {
2159
+ let raw = null;
2160
+ try {
2161
+ raw = await this.store.read(this.key);
2162
+ } catch {
2163
+ return;
2164
+ }
2165
+ if (!raw) return;
2166
+ try {
2167
+ const parsed = JSON.parse(raw);
2168
+ const slots = {};
2169
+ if (isPlainObject(parsed) && isPlainObject(parsed["slots"])) for (const [k, v] of Object.entries(parsed["slots"])) {
2170
+ const slot = validSlot(v);
2171
+ if (slot) slots[k] = slot;
2172
+ }
2173
+ this.record = {
2174
+ v: 1,
2175
+ slots
2176
+ };
2177
+ } catch {
2178
+ this.record = {
2179
+ v: 1,
2180
+ slots: {}
2181
+ };
2182
+ this.persist();
2183
+ }
2184
+ }
2185
+ /** Take a ticket for a request about to be sent for the current user. */
2186
+ ticket() {
2187
+ return {
2188
+ generation: this.generation,
2189
+ requestedFor: this.currentUser()
2190
+ };
2191
+ }
2192
+ /** The value for config `key` (fetched, cached, then default); `undefined` when none. */
2193
+ configValue(key) {
2194
+ this.ensureView();
2195
+ const values = this.config?.values;
2196
+ if (values && Object.prototype.hasOwnProperty.call(values, key)) {
2197
+ const v = values[key];
2198
+ if (v !== null && v !== void 0) return v;
2199
+ }
2200
+ return this.defaultFor(key);
2201
+ }
2202
+ /** The held assignment for flag `key`, or `null`. */
2203
+ flag(key) {
2204
+ this.ensureView();
2205
+ return this.flags?.find((f) => f.name === key) ?? null;
2206
+ }
2207
+ /** Whether flag `key` is on: the held assignment, then a boolean default. */
2208
+ flagEnabled(key) {
2209
+ const flag = this.flag(key);
2210
+ if (flag) return flag.enabled;
2211
+ const d = this.defaultFor(key);
2212
+ return typeof d === "boolean" ? d : void 0;
2213
+ }
2214
+ /**
2215
+ * Hold and persist a fetched bundle; returns it. A bundle the server
2216
+ * evaluated for nobody although the request carried a user (an expired or
2217
+ * revoked session) goes to the `anon` slot and leaves the user's values
2218
+ * alone; one evaluated for another user is not cached.
2219
+ */
2220
+ storeConfig(ticket, bundle, etag) {
2221
+ if (!isPlainObject(bundle.values)) return bundle;
2222
+ const evaluatedFor = etagEvaluatedFor(etag);
2223
+ let target;
2224
+ if (ticket.requestedFor && evaluatedFor === null) target = ANON_SLOT;
2225
+ else if (typeof evaluatedFor === "string" && evaluatedFor !== ticket.requestedFor) return bundle;
2226
+ this.write(ticket, target, (slot, held) => {
2227
+ slot.config = bundle;
2228
+ if (held) this.config = bundle;
2229
+ });
2230
+ return bundle;
2231
+ }
2232
+ /** Hold and persist a fetched flag list (it replaces the held one). */
2233
+ storeFlags(ticket, flags) {
2234
+ this.write(ticket, void 0, (slot, held) => {
2235
+ slot.flags = [...flags];
2236
+ if (held) this.flags = [...flags];
2237
+ });
2238
+ return flags;
2239
+ }
2240
+ /** Hold and persist one flag from a single-flag lookup; returns it. */
2241
+ upsertFlag(ticket, flag) {
2242
+ this.write(ticket, void 0, (slot, held) => {
2243
+ this.adoptHeldFlags(slot, held);
2244
+ slot.flags = slot.flags ?? [];
2245
+ upsert(slot.flags, flag);
2246
+ if (held) {
2247
+ this.flags = this.flags ?? [];
2248
+ upsert(this.flags, flag);
2249
+ }
2250
+ });
2251
+ return flag;
2252
+ }
2253
+ /** Drop flag `key` after a single-flag lookup found it off or unknown. */
2254
+ removeFlag(ticket, key) {
2255
+ this.write(ticket, void 0, (slot, held) => {
2256
+ this.adoptHeldFlags(slot, held);
2257
+ if (slot.flags) slot.flags = slot.flags.filter((f) => f.name !== key);
2258
+ if (held && this.flags) this.flags = this.flags.filter((f) => f.name !== key);
2259
+ });
2260
+ }
2261
+ /**
2262
+ * Sign-out: delete the signed-out user's slot and drop the held values;
2263
+ * later lookups serve the `anon` slot, then the defaults.
2264
+ */
2265
+ clearForSignOut(signedOutUser) {
2266
+ this.generation += 1;
2267
+ this.config = null;
2268
+ this.flags = null;
2269
+ this.viewFor = null;
2270
+ if (!signedOutUser) return;
2271
+ const doomed = slotKey(signedOutUser);
2272
+ if (this.record.slots[doomed]) {
2273
+ delete this.record.slots[doomed];
2274
+ this.persist();
2275
+ }
2276
+ }
2277
+ /** Resolves when every write so far reached storage. */
2278
+ flushed() {
2279
+ return this.writes;
2280
+ }
2281
+ /**
2282
+ * Before a single-flag change to the current user's slot: when the slot
2283
+ * holds no flag list and the served list is the signed-out one, the
2284
+ * user's slot starts from that list, so after a relaunch the user reads
2285
+ * the same flags the change was made on.
2286
+ */
2287
+ adoptHeldFlags(slot, held) {
2288
+ if (held && !slot.flags && this.flags) slot.flags = [...this.flags];
2289
+ }
2290
+ defaultFor(key) {
2291
+ if (!Object.prototype.hasOwnProperty.call(this.defaults, key)) return void 0;
2292
+ const v = this.defaults[key];
2293
+ return v === null ? void 0 : v;
2294
+ }
2295
+ /**
2296
+ * `held` is `true` when the slot written is the one the current identity
2297
+ * reads, so the served values change with it.
2298
+ */
2299
+ write(ticket, targetOverride, apply) {
2300
+ const current = slotKey(this.currentUser());
2301
+ if (ticket.generation !== this.generation || slotKey(ticket.requestedFor) !== current) return;
2302
+ this.ensureView();
2303
+ const target = targetOverride ?? current;
2304
+ const existing = this.record.slots[target];
2305
+ const slot = existing ? {
2306
+ saved_at: existing.saved_at,
2307
+ ...existing.config ? { config: existing.config } : {},
2308
+ ...existing.flags ? { flags: [...existing.flags] } : {}
2309
+ } : { saved_at: 0 };
2310
+ apply(slot, target === current);
2311
+ if (!slot.config && !slot.flags) {
2312
+ if (!existing) return;
2313
+ delete this.record.slots[target];
2314
+ } else {
2315
+ slot.saved_at = Date.now();
2316
+ this.record.slots[target] = slot;
2317
+ this.prune(target);
2318
+ }
2319
+ this.persist();
2320
+ if (target !== current) this.viewFor = null;
2321
+ }
2322
+ /** Serve the current identity's own slot, the `anon` slot for the rest. */
2323
+ ensureView() {
2324
+ const current = slotKey(this.currentUser());
2325
+ if (this.viewFor === current) return;
2326
+ const own = this.record.slots[current];
2327
+ const anon = current === ANON_SLOT ? void 0 : this.record.slots[ANON_SLOT];
2328
+ const config = own?.config ?? anon?.config;
2329
+ const flags = own?.flags ?? anon?.flags;
2330
+ this.config = config ?? null;
2331
+ this.flags = flags ? [...flags] : null;
2332
+ this.viewFor = current;
2333
+ }
2334
+ /**
2335
+ * Keep the MAX_SLOTS most recently written user slots; `keep` and the
2336
+ * signed-out slot (read before sign-in and for every user's gaps) stay.
2337
+ */
2338
+ prune(keep) {
2339
+ const keys = Object.keys(this.record.slots).filter((k) => k !== ANON_SLOT);
2340
+ while (keys.length > MAX_SLOTS) {
2341
+ let oldest = null;
2342
+ for (const k of keys) {
2343
+ if (k === keep) continue;
2344
+ if (oldest === null || this.record.slots[k].saved_at < this.record.slots[oldest].saved_at) oldest = k;
2345
+ }
2346
+ if (oldest === null) break;
2347
+ delete this.record.slots[oldest];
2348
+ keys.splice(keys.indexOf(oldest), 1);
2349
+ }
2350
+ }
2351
+ persist() {
2352
+ const text = Object.keys(this.record.slots).length === 0 ? null : JSON.stringify(this.record);
2353
+ this.writes = this.writes.then(() => text === null ? this.store.delete(this.key) : this.store.write(this.key, text)).catch(() => {});
2354
+ }
2355
+ };
2356
+
1821
2357
  //#endregion
1822
2358
  //#region src/client-info.ts
1823
2359
  /**
@@ -1918,6 +2454,7 @@ var CollectionSubscription = class {
1918
2454
  return openStream({
1919
2455
  url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/collections/${pathSegment(this.name)}${qs}`,
1920
2456
  authHeaders: this.ctx.authHeaders,
2457
+ connectTimeoutMs: this.ctx.connectTimeoutMs,
1921
2458
  onChange: cb,
1922
2459
  options,
1923
2460
  parse: parseRowChangeFrame
@@ -1942,6 +2479,7 @@ var ConversationSubscription = class {
1942
2479
  return openStream({
1943
2480
  url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/conversations/${pathSegment(this.conversationId)}`,
1944
2481
  authHeaders: this.ctx.authHeaders,
2482
+ connectTimeoutMs: this.ctx.connectTimeoutMs,
1945
2483
  onChange: cb,
1946
2484
  options,
1947
2485
  parse: parseRowChangeFrame
@@ -1966,6 +2504,7 @@ var GamificationSubscription = class {
1966
2504
  return openStream({
1967
2505
  url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/gamification`,
1968
2506
  authHeaders: this.ctx.authHeaders,
2507
+ connectTimeoutMs: this.ctx.connectTimeoutMs,
1969
2508
  onChange: cb,
1970
2509
  options,
1971
2510
  parse: parseGamificationFrame
@@ -2000,6 +2539,7 @@ function openStream(args) {
2000
2539
  closeActive = connectOnce({
2001
2540
  url: args.url,
2002
2541
  headers,
2542
+ connectTimeoutMs: args.connectTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
2003
2543
  onFrame: (frame) => {
2004
2544
  const ev = args.parse(frame);
2005
2545
  if (ev !== null) args.onChange(ev);
@@ -2070,8 +2610,13 @@ function connectXhr(args, XHR) {
2070
2610
  xhr.open("GET", args.url, true);
2071
2611
  for (const [k, v] of Object.entries(args.headers)) xhr.setRequestHeader(k, v);
2072
2612
  xhr.setRequestHeader("Accept", "text/event-stream");
2613
+ const connectTimer = setTimeout(() => {
2614
+ if (aborted || xhr.readyState >= 2) return;
2615
+ args.onError(/* @__PURE__ */ new Error(`realtime connection timed out after ${args.connectTimeoutMs} ms`));
2616
+ }, args.connectTimeoutMs);
2073
2617
  xhr.onreadystatechange = () => {
2074
2618
  if (aborted) return;
2619
+ if (xhr.readyState >= 2) clearTimeout(connectTimer);
2075
2620
  if (xhr.readyState === 3) {
2076
2621
  if (xhr.status >= 400) return;
2077
2622
  drain();
@@ -2095,10 +2640,12 @@ function connectXhr(args, XHR) {
2095
2640
  try {
2096
2641
  xhr.send();
2097
2642
  } catch (err) {
2643
+ clearTimeout(connectTimer);
2098
2644
  args.onError(err);
2099
2645
  }
2100
2646
  return () => {
2101
2647
  aborted = true;
2648
+ clearTimeout(connectTimer);
2102
2649
  try {
2103
2650
  xhr.abort();
2104
2651
  } catch {}
@@ -2113,14 +2660,14 @@ function connectFetchStream(args) {
2113
2660
  let aborted = false;
2114
2661
  (async () => {
2115
2662
  try {
2116
- const resp = await fetch(args.url, {
2663
+ const resp = await raceDeadline(fetch(args.url, {
2117
2664
  method: "GET",
2118
2665
  headers: {
2119
2666
  ...args.headers,
2120
2667
  Accept: "text/event-stream"
2121
2668
  },
2122
2669
  signal: controller.signal
2123
- });
2670
+ }), args.connectTimeoutMs, controller, (ms) => /* @__PURE__ */ new Error(`realtime connection timed out after ${ms} ms`));
2124
2671
  if (!resp.ok || !resp.body) throw new Error(`realtime connect failed: HTTP ${resp.status}`);
2125
2672
  const reader = resp.body.getReader();
2126
2673
  const decoder = new TextDecoder();
@@ -2200,7 +2747,7 @@ function delay(ms) {
2200
2747
  //#endregion
2201
2748
  //#region src/index.ts
2202
2749
  /** SDK semver — burned in at build time. Bump alongside package.json. */
2203
- const SDK_VERSION = "4.1.0";
2750
+ const SDK_VERSION = "4.2.1";
2204
2751
  /**
2205
2752
  * Default host that serves deployed functions. `Amba.functions.invoke(name)`
2206
2753
  * targets `https://<projectId>.<DEFAULT_FUNCTIONS_HOST>/<name>` unless a
@@ -2244,6 +2791,62 @@ const authSubscribers = /* @__PURE__ */ new Set();
2244
2791
  * start a new session.
2245
2792
  */
2246
2793
  const experimentReporter = new ExperimentAssignmentReporter();
2794
+ /**
2795
+ * `configDefaults` from the latest `configure()` call, recorded before
2796
+ * configure reads the AsyncStorage cache. `config.getCached` and
2797
+ * `flags.isEnabled` serve them on a first render that runs while
2798
+ * `configure()` is still in flight.
2799
+ */
2800
+ let _pendingConfigDefaults = null;
2801
+ /** Validate `AmbaConfig.configDefaults`: absent, or a plain object. */
2802
+ function readConfigDefaults(value) {
2803
+ if (value === void 0 || value === null) return null;
2804
+ if (typeof value !== "object" || Array.isArray(value)) throw new AmbaApiError("INVALID_CONFIG", "Amba.configure: configDefaults must be an object of config key to value");
2805
+ return value;
2806
+ }
2807
+ /** The pending `configDefaults` entry for `key`, ignoring inherited properties. */
2808
+ function pendingDefault(key) {
2809
+ const defaults = _pendingConfigDefaults;
2810
+ if (!defaults || !Object.prototype.hasOwnProperty.call(defaults, key)) return;
2811
+ const value = defaults[key];
2812
+ return value === null ? void 0 : value;
2813
+ }
2814
+ /** `"array"` for arrays, else `typeof` — the JSON kind of a value. */
2815
+ function jsonKind(value) {
2816
+ return Array.isArray(value) ? "array" : typeof value;
2817
+ }
2818
+ function configGetCached(key, defaultValue) {
2819
+ if (state) refreshOnFirstRead(state, "config");
2820
+ const raw = state ? state.remoteValues.configValue(key) : pendingDefault(key);
2821
+ if (raw === void 0 || raw === null) return defaultValue;
2822
+ if (defaultValue !== void 0 && defaultValue !== null && jsonKind(raw) !== jsonKind(defaultValue)) return defaultValue;
2823
+ return raw;
2824
+ }
2825
+ /** `Amba.flags.isEnabled`. */
2826
+ function flagsIsEnabled(key, defaultValue = false) {
2827
+ if (state) refreshOnFirstRead(state, "flags");
2828
+ const held = state ? state.remoteValues.flagEnabled(key) : pendingDefault(key);
2829
+ return typeof held === "boolean" ? held : defaultValue;
2830
+ }
2831
+ /** `Amba.flags.getCached`. */
2832
+ function flagsGetCached(key) {
2833
+ if (!state) return null;
2834
+ refreshOnFirstRead(state, "flags");
2835
+ return state.remoteValues.flag(key);
2836
+ }
2837
+ /**
2838
+ * The first on-device read of config (or flags) for each identity after
2839
+ * `configure()` starts one background fetch that replaces the cached values:
2840
+ * once at launch, and once more after a sign-in or sign-out changes who is
2841
+ * reading. Apps that never read cached values make no extra request. A
2842
+ * failure (offline) leaves the cached values in place.
2843
+ */
2844
+ function refreshOnFirstRead(s, kind) {
2845
+ const identity = s.identity.getAppUserId() ?? "";
2846
+ if (s.refreshedFor[kind] === identity) return;
2847
+ s.refreshedFor[kind] = identity;
2848
+ Promise.resolve().then(() => kind === "config" ? Amba.config.fetch() : Amba.flags.fetch()).catch(() => {});
2849
+ }
2247
2850
  function snapshotSession(user) {
2248
2851
  if (!user) return null;
2249
2852
  return {
@@ -2755,9 +3358,28 @@ function offlineStore(s) {
2755
3358
  };
2756
3359
  }
2757
3360
  /**
3361
+ * Whether a live `track` (outbox on) carries its idempotency key:
3362
+ * engagement events whose properties the keyed path takes. Telemetry stays
3363
+ * on the unkeyed hot path, which writes no idempotency record and answers a
3364
+ * failed telemetry insert with 200; its replays from the outbox are keyed.
3365
+ */
3366
+ function keyedLiveTrack(properties, telemetry) {
3367
+ return telemetry !== true && keyableProperties(properties);
3368
+ }
3369
+ /**
3370
+ * A JSON copy of `properties`: exactly what a request body serializes, and
3371
+ * immune to the app changing the object after `track()` returns.
3372
+ */
3373
+ function snapshotProperties(properties) {
3374
+ if (properties === void 0 || properties === null) return void 0;
3375
+ const text = JSON.stringify(properties);
3376
+ return text === void 0 ? void 0 : JSON.parse(text);
3377
+ }
3378
+ /**
2758
3379
  * Replay one queued mutation against the live API, forwarding the op's
2759
- * idempotency key on collection inserts so a write that landed before the
2760
- * connection dropped is never double-applied (API migration 056).
3380
+ * idempotency key on collection inserts and tracked events so a write that
3381
+ * landed before the connection dropped is never double-applied (API
3382
+ * migration 056 for collections, `client_event_idempotency` for events).
2761
3383
  */
2762
3384
  async function replayOutboxOp(s, op) {
2763
3385
  switch (op.kind) {
@@ -2779,8 +3401,9 @@ async function replayOutboxOp(s, op) {
2779
3401
  event: op.event,
2780
3402
  properties: op.properties ?? void 0,
2781
3403
  anonymous_id: s.identity.getAnonymousId(),
2782
- timestamp: op.occurredAt ?? (/* @__PURE__ */ new Date()).toISOString(),
2783
- ...op.telemetry ? { telemetry: true } : {}
3404
+ timestamp: op.occurredAt ?? op.enqueuedAt,
3405
+ ...op.telemetry ? { telemetry: true } : {},
3406
+ ...keyableProperties(op.properties) ? { idempotency_key: op.idempotencyKey } : {}
2784
3407
  });
2785
3408
  } catch (err) {
2786
3409
  const code = err?.code;
@@ -2862,10 +3485,13 @@ const Amba = {
2862
3485
  if (!Number.isInteger(flushIntervalMs) || flushIntervalMs < 0) throw new Error("Amba.configure: events.flushIntervalMs must be a non-negative integer");
2863
3486
  if (!Number.isInteger(maxQueue) || maxQueue < 1) throw new Error("Amba.configure: events.maxQueue must be positive");
2864
3487
  }
3488
+ const deadlines = resolveRequestDeadlines(config);
2865
3489
  const tokenStorage = config.tokenStorage ?? "secure";
2866
3490
  if (!TOKEN_STORAGE_MODES.includes(tokenStorage)) throw new Error("Amba.configure: tokenStorage must be \"secure\", \"memory\" or \"asyncStorage\"");
2867
3491
  const secureStorage = config.secureStorage;
2868
3492
  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");
3493
+ const configDefaults = readConfigDefaults(config.configDefaults);
3494
+ _pendingConfigDefaults = configDefaults;
2869
3495
  const storagePrefix = config.storagePrefix ?? "amba";
2870
3496
  const persistence = new AsyncStoragePersistence(storagePrefix);
2871
3497
  if (config.events?.batching) await persistence.assertDurable();
@@ -2873,6 +3499,8 @@ const Amba = {
2873
3499
  const vault = await resolveTokenVault(tokenStorage, storagePrefix, secureStorage);
2874
3500
  const identity = await IdentityStore.create(persistence, vault);
2875
3501
  const baseUrl = (config.apiUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
3502
+ const remoteValues = new RemoteValuesCache(persistence, recordKey(baseUrl, config.clientKey), configDefaults ?? {}, () => identity.getAppUserId());
3503
+ await remoteValues.load();
2876
3504
  const sdkVersionHeader = `amba-sdk/${SDK_VERSION} ${`amba-react-native/${SDK_VERSION}`}`;
2877
3505
  const http = new AmbaHttpClient({
2878
3506
  apiKey: config.clientKey,
@@ -2883,6 +3511,8 @@ const Amba = {
2883
3511
  deviceRegion: detectDeviceRegion(),
2884
3512
  deviceOs: detectDeviceOs(),
2885
3513
  identity,
3514
+ requestTimeoutMs: deadlines.requestMs,
3515
+ longRequestTimeoutMs: deadlines.longMs,
2886
3516
  ensureSession: config.autoAnonymousSession === false ? void 0 : async (path) => {
2887
3517
  if (pathSkipsLazySession(path)) return;
2888
3518
  await ensureAnonymousSession(getState());
@@ -2906,6 +3536,11 @@ const Amba = {
2906
3536
  resolvedFunctionsProjectId: null,
2907
3537
  functionsProjectIdInflight: null,
2908
3538
  persistence,
3539
+ remoteValues,
3540
+ refreshedFor: {
3541
+ config: null,
3542
+ flags: null
3543
+ },
2909
3544
  offlineEnabled: false,
2910
3545
  offlineQueue: null,
2911
3546
  eventQueue: null
@@ -2958,12 +3593,14 @@ const Amba = {
2958
3593
  },
2959
3594
  collection: (name) => new CollectionSubscription({
2960
3595
  baseUrl: getState().baseUrl,
2961
- authHeaders: realtimeAuthHeaders
3596
+ authHeaders: realtimeAuthHeaders,
3597
+ connectTimeoutMs: getState().http.requestTimeoutMs
2962
3598
  }, name),
2963
3599
  get gamification() {
2964
3600
  return new GamificationSubscription({
2965
3601
  baseUrl: getState().baseUrl,
2966
- authHeaders: realtimeAuthHeaders
3602
+ authHeaders: realtimeAuthHeaders,
3603
+ connectTimeoutMs: getState().http.requestTimeoutMs
2967
3604
  });
2968
3605
  },
2969
3606
  events: {
@@ -2971,13 +3608,16 @@ const Amba = {
2971
3608
  const s = getState();
2972
3609
  if (s.eventQueue && !options?.immediate) return s.eventQueue.enqueue(event, properties, options?.telemetry === true);
2973
3610
  const occurredAt = (/* @__PURE__ */ new Date()).toISOString();
3611
+ const props = s.offlineEnabled ? snapshotProperties(properties) : properties;
2974
3612
  const body = {
2975
3613
  event,
2976
- properties: properties ?? void 0,
3614
+ properties: props ?? void 0,
2977
3615
  anonymous_id: s.identity.getAnonymousId(),
2978
3616
  timestamp: occurredAt
2979
3617
  };
2980
3618
  if (options?.telemetry) body["telemetry"] = true;
3619
+ const idempotencyKey = s.offlineEnabled ? newIdempotencyKey() : void 0;
3620
+ if (idempotencyKey && keyedLiveTrack(props, options?.telemetry)) body["idempotency_key"] = idempotencyKey;
2981
3621
  const outboxTicket = s.offlineEnabled ? getOfflineQueue(s).ticket() : void 0;
2982
3622
  try {
2983
3623
  await s.http.post(P.events, body);
@@ -2986,9 +3626,10 @@ const Amba = {
2986
3626
  await getOfflineQueue(s).enqueue({
2987
3627
  kind: "track",
2988
3628
  event,
2989
- properties,
3629
+ properties: props,
2990
3630
  telemetry: options?.telemetry,
2991
- occurredAt
3631
+ occurredAt,
3632
+ idempotencyKey
2992
3633
  }, outboxTicket);
2993
3634
  return;
2994
3635
  }
@@ -3156,7 +3797,10 @@ const Amba = {
3156
3797
  } catch (err) {
3157
3798
  if (typeof console !== "undefined") console.warn("[amba] signOut server call failed", err);
3158
3799
  }
3800
+ const signedOutUser = s.identity.getAppUserId();
3159
3801
  await s.identity.clearSession(rotateAnonymousId);
3802
+ s.remoteValues.clearForSignOut(signedOutUser);
3803
+ await s.remoteValues.flushed();
3160
3804
  try {
3161
3805
  await getOfflineQueue(s).purge();
3162
3806
  } catch (err) {
@@ -3447,21 +4091,25 @@ const Amba = {
3447
4091
  await getState().http.delete(`${P.storage}/${encodePathSegment(assetId)}`);
3448
4092
  },
3449
4093
  download: async (assetId) => {
3450
- const assetEnv = await getState().http.get(`${P.storage}/${encodePathSegment(assetId)}`);
4094
+ const s = getState();
4095
+ const assetEnv = await s.http.get(`${P.storage}/${encodePathSegment(assetId)}`);
3451
4096
  const cdnUrl = assetEnv.data.url ?? assetEnv.data.storage_url ?? "";
3452
4097
  if (!cdnUrl) throw new AmbaApiError("NOT_FOUND", "asset has no resolvable CDN url");
3453
- let response;
3454
- try {
3455
- response = await (globalThis.fetch ?? fetch)(cdnUrl, { method: "GET" });
3456
- } catch (err) {
3457
- throw new AmbaApiError("NETWORK_ERROR", `Network error: ${err instanceof Error ? err.message : String(err)}`, err);
3458
- }
3459
- if (!response.ok) {
3460
- const text = await response.text();
3461
- const ct = response.headers.get("content-type") ?? "";
3462
- throw mapHttpError(response, text, ct.includes("application/json"));
3463
- }
3464
- return response.blob();
4098
+ const sizeBytes = assetEnv.data.size_bytes;
4099
+ const { response, blob, text } = await s.http.send(cdnUrl, { method: "GET" }, async (res) => res.ok ? {
4100
+ response: res,
4101
+ blob: await res.blob(),
4102
+ text: ""
4103
+ } : {
4104
+ response: res,
4105
+ blob: null,
4106
+ text: await res.text()
4107
+ }, {
4108
+ timeoutMs: s.http.longRequestTimeoutMs,
4109
+ responseBytes: typeof sizeBytes === "number" ? sizeBytes : void 0
4110
+ });
4111
+ if (!blob) throw mapHttpError(response, text, (response.headers.get("content-type") ?? "").includes("application/json"));
4112
+ return blob;
3465
4113
  }
3466
4114
  },
3467
4115
  push: {
@@ -3509,27 +4157,30 @@ const Amba = {
3509
4157
  functions: { invoke: async (name, body, options) => {
3510
4158
  const s = getState();
3511
4159
  if (!name || name.trim() === "") throw new AmbaApiError("INVALID_ARGUMENT", "Amba.functions.invoke: a function name is required.");
4160
+ let timeoutMs;
4161
+ try {
4162
+ timeoutMs = readTimeout("Amba.functions.invoke: timeoutMs", options?.timeoutMs, s.http.longRequestTimeoutMs);
4163
+ } catch (err) {
4164
+ throw new AmbaApiError("INVALID_ARGUMENT", err.message);
4165
+ }
3512
4166
  const url = await resolveFunctionUrl(s, name);
3513
4167
  const method = options?.method ?? "POST";
3514
4168
  const headers = s.http.buildHeaders(options?.headers);
3515
4169
  let requestBody;
3516
4170
  if (body !== void 0 && method !== "GET") requestBody = JSON.stringify(body);
3517
4171
  else delete headers["Content-Type"];
3518
- let response;
3519
- try {
3520
- response = await (globalThis.fetch ?? fetch)(url, {
3521
- method,
3522
- headers,
3523
- body: requestBody
3524
- });
3525
- } catch (err) {
3526
- throw new AmbaApiError("NETWORK_ERROR", `Amba.functions.invoke("${name}") network error: ${err instanceof Error ? err.message : String(err)}`, err);
3527
- }
3528
- const text = await response.text();
3529
- if (!response.ok) {
3530
- const ct = response.headers.get("content-type") ?? "";
3531
- throw mapHttpError(response, text, ct.includes("application/json"));
3532
- }
4172
+ const { response, text } = await s.http.send(url, {
4173
+ method,
4174
+ headers,
4175
+ body: requestBody
4176
+ }, async (res) => ({
4177
+ response: res,
4178
+ text: await res.text()
4179
+ }), {
4180
+ timeoutMs,
4181
+ label: `Amba.functions.invoke("${name}") network error`
4182
+ });
4183
+ if (!response.ok) throw mapHttpError(response, text, (response.headers.get("content-type") ?? "").includes("application/json"));
3533
4184
  if (!text) return void 0;
3534
4185
  try {
3535
4186
  return JSON.parse(text);
@@ -3537,54 +4188,63 @@ const Amba = {
3537
4188
  return text;
3538
4189
  }
3539
4190
  } },
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, {
4191
+ config: {
4192
+ fetch: async () => {
4193
+ const s = getState();
4194
+ const ticket = s.remoteValues.ticket();
4195
+ const url = s.http.url(P.config);
4196
+ const headers = s.http.buildHeaders();
4197
+ const { response, text } = await s.http.send(url, {
3547
4198
  method: "GET",
3548
4199
  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
- } },
4200
+ }, async (res) => ({
4201
+ response: res,
4202
+ text: await res.text()
4203
+ }));
4204
+ if (!response.ok) throw mapHttpError(response, text, (response.headers.get("content-type") ?? "").includes("application/json"));
4205
+ let parsed = {};
4206
+ if (text) try {
4207
+ parsed = JSON.parse(text);
4208
+ } catch {
4209
+ parsed = {};
4210
+ }
4211
+ const etag = response.headers.get("etag");
4212
+ let version = null;
4213
+ if (etag) {
4214
+ const cleaned = etag.replace(/^"|"$/g, "");
4215
+ const m = cleaned.match(/^(v\d+)/);
4216
+ version = m ? m[1] ?? null : cleaned || null;
4217
+ }
4218
+ return s.remoteValues.storeConfig(ticket, {
4219
+ version,
4220
+ values: parsed.data ?? {}
4221
+ }, etag);
4222
+ },
4223
+ getCached: configGetCached
4224
+ },
3576
4225
  flags: {
3577
4226
  fetch: async () => {
3578
- return (await getState().http.get(P.flags)).data;
4227
+ const s = getState();
4228
+ const ticket = s.remoteValues.ticket();
4229
+ const env = await s.http.get(P.flags);
4230
+ return s.remoteValues.storeFlags(ticket, env.data);
3579
4231
  },
3580
4232
  get: async (key) => {
4233
+ const s = getState();
4234
+ const ticket = s.remoteValues.ticket();
3581
4235
  try {
3582
- return (await getState().http.get(`${P.flags}/${encodePathSegment(key)}`)).data;
4236
+ const env = await s.http.get(`${P.flags}/${encodePathSegment(key)}`);
4237
+ return s.remoteValues.upsertFlag(ticket, env.data);
3583
4238
  } catch (err) {
3584
- if (err instanceof AmbaApiError && err.code === "NOT_FOUND") return null;
4239
+ if (err instanceof AmbaApiError && err.code === "NOT_FOUND") {
4240
+ s.remoteValues.removeFlag(ticket, key);
4241
+ return null;
4242
+ }
3585
4243
  throw err;
3586
4244
  }
3587
- }
4245
+ },
4246
+ getCached: flagsGetCached,
4247
+ isEnabled: flagsIsEnabled
3588
4248
  },
3589
4249
  experiments: {
3590
4250
  getVariant: async (key) => {
@@ -3764,7 +4424,8 @@ const Amba = {
3764
4424
  messaging: {
3765
4425
  conversation: (conversationId) => new ConversationSubscription({
3766
4426
  baseUrl: getState().baseUrl,
3767
- authHeaders: realtimeAuthHeaders
4427
+ authHeaders: realtimeAuthHeaders,
4428
+ connectTimeoutMs: getState().http.requestTimeoutMs
3768
4429
  }, conversationId),
3769
4430
  createConversation: async (request) => {
3770
4431
  return (await getState().http.post(`${P.messaging}/conversations`, request)).data;