@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.js CHANGED
@@ -90,6 +90,167 @@ var AmbaApiError = class extends Error {
90
90
  }
91
91
  };
92
92
 
93
+ //#endregion
94
+ //#region src/request-deadline.ts
95
+ /**
96
+ * Request deadlines, shared by every JS SDK target (the same file ships in
97
+ * `@layers/amba-web`, `@layers/amba-node` and `@layers/amba-react-native`).
98
+ *
99
+ * Every request the SDK sends runs under a deadline that covers sending it
100
+ * and reading the whole response. When the deadline passes, the request is
101
+ * aborted and fails with the SDK's retryable network error, so one request
102
+ * can never hold an in-flight slot (the offline outbox flush, the lazy
103
+ * anonymous sign-in, the session refresh, the event batch flush) for the
104
+ * life of the process. Some runtimes never settle a fetch they gave up on:
105
+ * Expo on Android drops a failed DNS lookup and the promise stays pending.
106
+ * The deadline wins the race even then, because it rejects on its own timer
107
+ * and then aborts the fetch for the runtime to clean up.
108
+ *
109
+ * Two bounds:
110
+ * - `requestTimeoutMs` (default 15 s): an ordinary API request.
111
+ * - `longRequestTimeoutMs` (default 5 min): work that legitimately runs
112
+ * longer. AI generation (`/v1/client/ai/*`), function invocations, and
113
+ * media uploads and downloads.
114
+ * Both grow with the bytes a request moves (see `withTransferAllowance`), so
115
+ * a large upload, download or event batch on a slow link has time to finish.
116
+ * Realtime subscriptions are bounded until their stream opens; AI response
117
+ * streams likewise until the response starts. Once open, a stream runs for
118
+ * as long as the caller keeps it.
119
+ */
120
+ /** Default deadline for one API request, in milliseconds. */
121
+ const DEFAULT_REQUEST_TIMEOUT_MS = 15e3;
122
+ /**
123
+ * Default deadline for AI generation, function invocations and media
124
+ * transfers, in milliseconds.
125
+ */
126
+ const DEFAULT_LONG_REQUEST_TIMEOUT_MS = 3e5;
127
+ /**
128
+ * The slowest link a deadline makes room for, in bytes per second
129
+ * (8 KiB/s, about 64 kbit/s). Each byte a request sends or receives beyond
130
+ * the first {@link TRANSFER_BYTES_WITHIN_BASE} adds the time it takes at this
131
+ * rate to the deadline.
132
+ */
133
+ const MIN_TRANSFER_BYTES_PER_SECOND = 8 * 1024;
134
+ /**
135
+ * Bytes a request moves within its base deadline (64 KiB: 8 s at the
136
+ * slowest rate). An ordinary API request stays at exactly
137
+ * `requestTimeoutMs`; only larger bodies widen it.
138
+ */
139
+ const TRANSFER_BYTES_WITHIN_BASE = 64 * 1024;
140
+ /** The largest delay `setTimeout` honors (about 24.8 days). */
141
+ const MAX_TIMEOUT_MS = 2147483647;
142
+ /**
143
+ * Read the deadlines from the configure options, applying the defaults.
144
+ * Throws an `Error` naming the option when a value is not a positive number
145
+ * of milliseconds.
146
+ */
147
+ function resolveRequestDeadlines(options) {
148
+ return {
149
+ requestMs: readTimeout("Amba.configure: requestTimeoutMs", options.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS),
150
+ longMs: readTimeout("Amba.configure: longRequestTimeoutMs", options.longRequestTimeoutMs, DEFAULT_LONG_REQUEST_TIMEOUT_MS)
151
+ };
152
+ }
153
+ /**
154
+ * Validate one timeout value; `undefined` keeps `fallback`. `label` names the
155
+ * option in the thrown `Error` (`Amba.configure: requestTimeoutMs`).
156
+ */
157
+ function readTimeout(label, value, fallback) {
158
+ if (value === void 0) return fallback;
159
+ 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}`);
160
+ return value;
161
+ }
162
+ /** The text a deadline failure carries, e.g. `request timed out after 15000 ms`. */
163
+ function timeoutMessage(timeoutMs) {
164
+ return `request timed out after ${timeoutMs} ms`;
165
+ }
166
+ /**
167
+ * `timeoutMs` plus the time the bytes beyond the first
168
+ * {@link TRANSFER_BYTES_WITHIN_BASE} take at
169
+ * {@link MIN_TRANSFER_BYTES_PER_SECOND}, capped at the largest delay
170
+ * `setTimeout` honors. Unknown sizes add nothing.
171
+ */
172
+ function withTransferAllowance(timeoutMs, bytes) {
173
+ const beyond = typeof bytes === "number" && Number.isFinite(bytes) ? bytes - TRANSFER_BYTES_WITHIN_BASE : 0;
174
+ const extra = beyond > 0 ? Math.ceil(beyond / MIN_TRANSFER_BYTES_PER_SECOND * 1e3) : 0;
175
+ return Math.min(MAX_TIMEOUT_MS, timeoutMs + extra);
176
+ }
177
+ /**
178
+ * The size of a request body in bytes: a string's UTF-8 length, a buffer's
179
+ * byte length, a `Blob`'s size. `undefined` for no body or a body of unknown
180
+ * size (a stream).
181
+ */
182
+ function bodyByteLength(body) {
183
+ if (body === void 0 || body === null) return void 0;
184
+ if (typeof body === "string") return typeof TextEncoder !== "undefined" ? new TextEncoder().encode(body).length : body.length;
185
+ if (typeof ArrayBuffer !== "undefined") {
186
+ if (body instanceof ArrayBuffer) return body.byteLength;
187
+ if (ArrayBuffer.isView(body)) return body.byteLength;
188
+ }
189
+ const size = body.size;
190
+ return typeof size === "number" ? size : void 0;
191
+ }
192
+ /** A response's `Content-Length`, when it states one. */
193
+ function contentLength(response) {
194
+ const raw = response.headers?.get?.("content-length");
195
+ if (!raw) return void 0;
196
+ const n = Number(raw);
197
+ return Number.isFinite(n) && n >= 0 ? n : void 0;
198
+ }
199
+ /** The paths under the API base URL whose requests get the long deadline. */
200
+ const LONG_API_PATH_PREFIXES = ["/v1/client/ai/"];
201
+ /** Whether a request to API `path` (starting `/v1/...`) gets the long deadline. */
202
+ function isLongApiPath(path) {
203
+ return LONG_API_PATH_PREFIXES.some((prefix) => path.startsWith(prefix));
204
+ }
205
+ /**
206
+ * Race `work` against a timer of `ms`. When the timer fires first the race
207
+ * rejects with `onTimeout(ms)`, then `controller` is aborted so the runtime
208
+ * can release the connection. The rejection does not wait for `work`, so a
209
+ * fetch the runtime never settles still releases its caller on time. The
210
+ * timer is cleared as soon as either side settles.
211
+ */
212
+ function raceDeadline(work, ms, controller, onTimeout) {
213
+ let timer;
214
+ const deadline = new Promise((_, reject) => {
215
+ timer = setTimeout(() => {
216
+ reject(onTimeout(ms));
217
+ try {
218
+ controller?.abort();
219
+ } catch {}
220
+ }, ms);
221
+ });
222
+ return Promise.race([work, deadline]).finally(() => {
223
+ clearTimeout(timer);
224
+ });
225
+ }
226
+ function newController() {
227
+ return typeof AbortController === "function" ? new AbortController() : void 0;
228
+ }
229
+ /**
230
+ * Send a request and read its response under deadlines that grow with the
231
+ * bytes involved. Sending, until `send` resolves with the response, gets
232
+ * `timeoutMs` plus the allowance for the request body (and, with
233
+ * `fetchBuffersBody`, for the expected response bytes). Reading,
234
+ * `read(response)`, gets `timeoutMs` plus the allowance for the expected
235
+ * response bytes, else the response's `Content-Length`. One `AbortSignal`
236
+ * covers both phases, so a read past its deadline aborts the transfer too.
237
+ * Past either deadline the promise rejects with `onTimeout(ms)`.
238
+ */
239
+ async function exchange(timeoutMs, send, read, onTimeout, sizes = {}) {
240
+ const controller = newController();
241
+ const sendMs = withTransferAllowance(timeoutMs, (sizes.requestBytes ?? 0) + (sizes.fetchBuffersBody ? sizes.responseBytes ?? 0 : 0));
242
+ const response = await raceDeadline(send(controller?.signal), sendMs, controller, onTimeout);
243
+ const readMs = withTransferAllowance(timeoutMs, sizes.responseBytes ?? contentLength(response));
244
+ return raceDeadline(read(response), readMs, controller, onTimeout);
245
+ }
246
+ /** `init` with `signal` set, when there is one. */
247
+ function withSignal(init, signal) {
248
+ return signal ? {
249
+ ...init,
250
+ signal
251
+ } : init;
252
+ }
253
+
93
254
  //#endregion
94
255
  //#region src/http-client.ts
95
256
  /** Default amba API endpoint. */
@@ -106,6 +267,12 @@ var AmbaHttpClient = class {
106
267
  deviceOs;
107
268
  identity;
108
269
  fetchImpl;
270
+ /** The `fetchImpl` option, when one was passed (`send` resolves fetch per call otherwise). */
271
+ injectedFetch;
272
+ /** Deadline for an API request, in milliseconds. */
273
+ requestTimeoutMs;
274
+ /** Deadline for AI generation, function invocations and media transfers. */
275
+ longRequestTimeoutMs;
109
276
  ensureSession;
110
277
  onSessionRefused;
111
278
  constructor(opts) {
@@ -119,7 +286,39 @@ var AmbaHttpClient = class {
119
286
  this.identity = opts.identity;
120
287
  this.ensureSession = opts.ensureSession;
121
288
  this.onSessionRefused = opts.onSessionRefused;
289
+ this.injectedFetch = opts.fetchImpl;
122
290
  this.fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
291
+ this.requestTimeoutMs = opts.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
292
+ this.longRequestTimeoutMs = opts.longRequestTimeoutMs ?? DEFAULT_LONG_REQUEST_TIMEOUT_MS;
293
+ }
294
+ /**
295
+ * Send a request to an absolute URL outside the API's JSON envelope (a
296
+ * function invocation, a CDN download, the config route that reads the
297
+ * `ETag`) under a deadline. `read` takes what the caller needs from the
298
+ * response (the body as text, a `Blob`) and runs inside the same
299
+ * deadline, so a body that stops arriving fails too.
300
+ *
301
+ * A transport failure rejects with `NETWORK_ERROR` and the message
302
+ * `<label>: <cause>`; the deadline rejects with `NETWORK_ERROR` and
303
+ * `<label>: request timed out after <ms> ms`. `label` defaults to
304
+ * `Network error` and `timeoutMs` to the request deadline; the deadline
305
+ * widens with the request body and with `responseBytes`, the size the
306
+ * caller expects to download (see `exchange`). An `AmbaApiError` that
307
+ * `read` throws passes through unchanged.
308
+ */
309
+ async send(url, init, read, opts = {}) {
310
+ const timeoutMs = opts.timeoutMs ?? this.requestTimeoutMs;
311
+ const label = opts.label ?? "Network error";
312
+ const fetchFn = this.injectedFetch ?? globalThis.fetch;
313
+ try {
314
+ return await exchange(timeoutMs, (signal) => fetchFn(url, withSignal(init, signal)), read, (ms) => timeoutError(label, ms), {
315
+ requestBytes: bodyByteLength(init.body),
316
+ responseBytes: opts.responseBytes,
317
+ fetchBuffersBody: true
318
+ });
319
+ } catch (err) {
320
+ throw asNetworkError(err, label);
321
+ }
123
322
  }
124
323
  /** Build the absolute URL for an API path, optionally with a query string. */
125
324
  url(path, query) {
@@ -179,14 +378,19 @@ var AmbaHttpClient = class {
179
378
  headers,
180
379
  body: body === void 0 ? void 0 : JSON.stringify(body ?? {})
181
380
  };
381
+ const timeoutMs = isLongApiPath(path) ? this.longRequestTimeoutMs : this.requestTimeoutMs;
182
382
  let response;
383
+ let text;
183
384
  try {
184
- response = await this.fetchImpl(url, init);
385
+ ({response, text} = await exchange(timeoutMs, (signal) => this.fetchImpl(url, withSignal(init, signal)), async (res) => ({
386
+ response: res,
387
+ text: res.status === 204 ? "" : await res.text()
388
+ }), (ms) => timeoutError("Network error", ms), { requestBytes: bodyByteLength(init.body) }));
185
389
  } catch (err) {
186
- throw new AmbaApiError("NETWORK_ERROR", `Network error: ${err instanceof Error ? err.message : typeof err === "string" ? err : "fetch failed"}`, err);
390
+ throw asNetworkError(err, "Network error");
187
391
  }
188
392
  try {
189
- return await parseResponse(response);
393
+ return parseResponse(response, text);
190
394
  } catch (err) {
191
395
  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);
192
396
  throw err;
@@ -201,9 +405,21 @@ var AmbaHttpClient = class {
201
405
  }
202
406
  }
203
407
  };
204
- async function parseResponse(response) {
408
+ /** The `NETWORK_ERROR` a passed deadline raises. */
409
+ function timeoutError(label, timeoutMs) {
410
+ return new AmbaApiError("NETWORK_ERROR", `${label}: ${timeoutMessage(timeoutMs)}`, { timeoutMs });
411
+ }
412
+ /**
413
+ * Wrap a transport failure as `NETWORK_ERROR` (`<label>: <cause>`). An
414
+ * `AmbaApiError` (the deadline's own, or one a `send` reader throws) passes
415
+ * through unchanged.
416
+ */
417
+ function asNetworkError(err, label) {
418
+ if (err instanceof AmbaApiError) return err;
419
+ return new AmbaApiError("NETWORK_ERROR", `${label}: ${err instanceof Error ? err.message : typeof err === "string" ? err : "fetch failed"}`, err);
420
+ }
421
+ function parseResponse(response, text) {
205
422
  if (response.status === 204) return null;
206
- const text = await response.text();
207
423
  const isJson = (response.headers.get("content-type") ?? "").includes("application/json");
208
424
  if (!response.ok) throw mapHttpError(response, text, isJson);
209
425
  if (text === "") return null;
@@ -899,7 +1115,16 @@ const DETERMINISTIC_ERROR_CODES = new Set([
899
1115
  "NOT_FOUND",
900
1116
  "CONFLICT",
901
1117
  "CONSENT_NOT_GRANTED",
902
- "NOT_INITIALIZED"
1118
+ "NOT_INITIALIZED",
1119
+ "INVALID_JSON",
1120
+ "INVALID_BODY",
1121
+ "INVALID_EVENT",
1122
+ "RESERVED_EVENT_NAME",
1123
+ "INVALID_IDEMPOTENCY_KEY",
1124
+ "INVALID_TIMESTAMP",
1125
+ "INVALID_PROPERTIES",
1126
+ "INVALID_TELEMETRY",
1127
+ "IDEMPOTENCY_KEY_CONFLICT"
903
1128
  ]);
904
1129
  /**
905
1130
  * Is this a deterministic server rejection — a coded error the server would
@@ -937,13 +1162,34 @@ function isOfflineError(err) {
937
1162
  if (typeof msg === "string" && /network (error|request failed)|failed to fetch|fetch failed|load failed/i.test(msg)) return true;
938
1163
  return !isOnline();
939
1164
  }
940
- /** Generate a stable idempotency key for a queued op. */
1165
+ /**
1166
+ * Generate a stable idempotency key for a queued op: `amba_obx_` and a random
1167
+ * UUID. The server records event keys project-wide, so the UUID comes from
1168
+ * the runtime's secure random source: `crypto.randomUUID`, else
1169
+ * `crypto.getRandomValues` (Hermes has only the latter).
1170
+ */
941
1171
  function newIdempotencyKey() {
942
1172
  const c = typeof globalThis !== "undefined" ? globalThis.crypto : void 0;
943
1173
  if (c && typeof c.randomUUID === "function") return `amba_obx_${c.randomUUID()}`;
1174
+ if (c && typeof c.getRandomValues === "function") {
1175
+ const b = c.getRandomValues(new Uint8Array(16));
1176
+ b[6] = b[6] & 15 | 64;
1177
+ b[8] = b[8] & 63 | 128;
1178
+ const h = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
1179
+ return `amba_obx_${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
1180
+ }
944
1181
  return `amba_obx_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`;
945
1182
  }
946
1183
  /**
1184
+ * Whether a `track` with these properties can take the events route's keyed
1185
+ * path, which accepts a plain object or no properties. A JS caller can pass
1186
+ * something else (an array); such an event stays on the unkeyed path, which
1187
+ * stores it as it always has.
1188
+ */
1189
+ function keyableProperties(properties) {
1190
+ return properties === void 0 || properties === null || typeof properties === "object" && !Array.isArray(properties);
1191
+ }
1192
+ /**
947
1193
  * Durable, ordered, idempotent offline outbox. Opt-in via `Amba.offline`.
948
1194
  * Single in-flight flush is enforced so concurrent reconnect signals don't
949
1195
  * double-replay.
@@ -1824,6 +2070,296 @@ function logListenerError(err) {
1824
2070
  console.warn("[amba] onExperimentAssigned listener threw", err);
1825
2071
  }
1826
2072
 
2073
+ //#endregion
2074
+ //#region src/remote-values.ts
2075
+ /** Prefix of the per-project AsyncStorage record key. */
2076
+ const CACHE_KEY_PREFIX = "amba_rc_v1_";
2077
+ /** Slot for values evaluated without a signed-in user. */
2078
+ const ANON_SLOT = "anon";
2079
+ /** Most user slots one project record keeps. */
2080
+ const MAX_SLOTS = 5;
2081
+ const TWO_32 = 4294967296;
2082
+ /**
2083
+ * 64-bit FNV-1a over the string's UTF-16 code units (low byte, then high
2084
+ * byte), as 16 hex digits. Computed in two 32-bit halves: some React Native
2085
+ * engines (JavaScriptCore on older Android builds) have no BigInt.
2086
+ */
2087
+ function fnv1a64Hex(text) {
2088
+ let hi = 3421674724;
2089
+ let lo = 2216829733;
2090
+ const step = (byte) => {
2091
+ lo = (lo ^ byte) >>> 0;
2092
+ const low = lo * 435;
2093
+ const carry = Math.floor(low / TWO_32);
2094
+ const high = hi * 435 + carry + (lo << 8 >>> 0);
2095
+ lo = low % TWO_32;
2096
+ hi = high % TWO_32;
2097
+ };
2098
+ for (let i = 0; i < text.length; i++) {
2099
+ const unit = text.charCodeAt(i);
2100
+ step(unit & 255);
2101
+ step(unit >>> 8);
2102
+ }
2103
+ return hi.toString(16).padStart(8, "0") + lo.toString(16).padStart(8, "0");
2104
+ }
2105
+ /** The record key for a project: a hash of the base URL and client key. */
2106
+ function recordKey(baseUrl, clientKey) {
2107
+ return `${CACHE_KEY_PREFIX}${fnv1a64Hex(`${baseUrl.replace(/\/+$/, "")}\n${clientKey}`)}`;
2108
+ }
2109
+ /** The slot key for an identity: `u` + a hash of the user id, or `anon`. */
2110
+ function slotKey(appUserId) {
2111
+ return appUserId ? `u${fnv1a64Hex(appUserId)}` : ANON_SLOT;
2112
+ }
2113
+ /**
2114
+ * The user a config response was evaluated for, from its `ETag`:
2115
+ * `"<version>:<app user id>:<segments>"` for a signed-in caller, `"<version>"`
2116
+ * for every other one (no session, or an expired or revoked token, which the
2117
+ * server answers with each key's default value). `undefined` without an
2118
+ * `ETag`; `null` for an evaluation for nobody.
2119
+ */
2120
+ function etagEvaluatedFor(etag) {
2121
+ if (!etag) return void 0;
2122
+ const parts = etag.trim().replace(/^W\//, "").replace(/^"|"$/g, "").split(":");
2123
+ return parts.length > 1 && parts[1] ? parts[1] : null;
2124
+ }
2125
+ function isPlainObject(value) {
2126
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2127
+ }
2128
+ function validSlot(raw) {
2129
+ if (!isPlainObject(raw)) return null;
2130
+ const slot = { saved_at: typeof raw["saved_at"] === "number" ? raw["saved_at"] : 0 };
2131
+ const config = raw["config"];
2132
+ if (isPlainObject(config) && isPlainObject(config["values"])) slot.config = {
2133
+ version: typeof config["version"] === "string" ? config["version"] : null,
2134
+ values: config["values"]
2135
+ };
2136
+ const flags = raw["flags"];
2137
+ if (Array.isArray(flags)) slot.flags = flags.filter((f) => isPlainObject(f) && typeof f["name"] === "string" && typeof f["enabled"] === "boolean");
2138
+ return slot.config || slot.flags ? slot : null;
2139
+ }
2140
+ function upsert(list, flag) {
2141
+ const i = list.findIndex((f) => f.name === flag.name);
2142
+ if (i >= 0) list[i] = flag;
2143
+ else list.push(flag);
2144
+ }
2145
+ var RemoteValuesCache = class {
2146
+ record = {
2147
+ v: 1,
2148
+ slots: {}
2149
+ };
2150
+ /** The slot key the held values were resolved for. */
2151
+ viewFor = null;
2152
+ config = null;
2153
+ flags = null;
2154
+ /** Bumped by every sign-out; an older ticket's response is not cached. */
2155
+ generation = 0;
2156
+ writes = Promise.resolve();
2157
+ constructor(store, key, defaults, currentUser) {
2158
+ this.store = store;
2159
+ this.key = key;
2160
+ this.defaults = defaults;
2161
+ this.currentUser = currentUser;
2162
+ }
2163
+ /** Read the project's record from storage. `configure()` awaits it. */
2164
+ async load() {
2165
+ let raw = null;
2166
+ try {
2167
+ raw = await this.store.read(this.key);
2168
+ } catch {
2169
+ return;
2170
+ }
2171
+ if (!raw) return;
2172
+ try {
2173
+ const parsed = JSON.parse(raw);
2174
+ const slots = {};
2175
+ if (isPlainObject(parsed) && isPlainObject(parsed["slots"])) for (const [k, v] of Object.entries(parsed["slots"])) {
2176
+ const slot = validSlot(v);
2177
+ if (slot) slots[k] = slot;
2178
+ }
2179
+ this.record = {
2180
+ v: 1,
2181
+ slots
2182
+ };
2183
+ } catch {
2184
+ this.record = {
2185
+ v: 1,
2186
+ slots: {}
2187
+ };
2188
+ this.persist();
2189
+ }
2190
+ }
2191
+ /** Take a ticket for a request about to be sent for the current user. */
2192
+ ticket() {
2193
+ return {
2194
+ generation: this.generation,
2195
+ requestedFor: this.currentUser()
2196
+ };
2197
+ }
2198
+ /** The value for config `key` (fetched, cached, then default); `undefined` when none. */
2199
+ configValue(key) {
2200
+ this.ensureView();
2201
+ const values = this.config?.values;
2202
+ if (values && Object.prototype.hasOwnProperty.call(values, key)) {
2203
+ const v = values[key];
2204
+ if (v !== null && v !== void 0) return v;
2205
+ }
2206
+ return this.defaultFor(key);
2207
+ }
2208
+ /** The held assignment for flag `key`, or `null`. */
2209
+ flag(key) {
2210
+ this.ensureView();
2211
+ return this.flags?.find((f) => f.name === key) ?? null;
2212
+ }
2213
+ /** Whether flag `key` is on: the held assignment, then a boolean default. */
2214
+ flagEnabled(key) {
2215
+ const flag = this.flag(key);
2216
+ if (flag) return flag.enabled;
2217
+ const d = this.defaultFor(key);
2218
+ return typeof d === "boolean" ? d : void 0;
2219
+ }
2220
+ /**
2221
+ * Hold and persist a fetched bundle; returns it. A bundle the server
2222
+ * evaluated for nobody although the request carried a user (an expired or
2223
+ * revoked session) goes to the `anon` slot and leaves the user's values
2224
+ * alone; one evaluated for another user is not cached.
2225
+ */
2226
+ storeConfig(ticket, bundle, etag) {
2227
+ if (!isPlainObject(bundle.values)) return bundle;
2228
+ const evaluatedFor = etagEvaluatedFor(etag);
2229
+ let target;
2230
+ if (ticket.requestedFor && evaluatedFor === null) target = ANON_SLOT;
2231
+ else if (typeof evaluatedFor === "string" && evaluatedFor !== ticket.requestedFor) return bundle;
2232
+ this.write(ticket, target, (slot, held) => {
2233
+ slot.config = bundle;
2234
+ if (held) this.config = bundle;
2235
+ });
2236
+ return bundle;
2237
+ }
2238
+ /** Hold and persist a fetched flag list (it replaces the held one). */
2239
+ storeFlags(ticket, flags) {
2240
+ this.write(ticket, void 0, (slot, held) => {
2241
+ slot.flags = [...flags];
2242
+ if (held) this.flags = [...flags];
2243
+ });
2244
+ return flags;
2245
+ }
2246
+ /** Hold and persist one flag from a single-flag lookup; returns it. */
2247
+ upsertFlag(ticket, flag) {
2248
+ this.write(ticket, void 0, (slot, held) => {
2249
+ this.adoptHeldFlags(slot, held);
2250
+ slot.flags = slot.flags ?? [];
2251
+ upsert(slot.flags, flag);
2252
+ if (held) {
2253
+ this.flags = this.flags ?? [];
2254
+ upsert(this.flags, flag);
2255
+ }
2256
+ });
2257
+ return flag;
2258
+ }
2259
+ /** Drop flag `key` after a single-flag lookup found it off or unknown. */
2260
+ removeFlag(ticket, key) {
2261
+ this.write(ticket, void 0, (slot, held) => {
2262
+ this.adoptHeldFlags(slot, held);
2263
+ if (slot.flags) slot.flags = slot.flags.filter((f) => f.name !== key);
2264
+ if (held && this.flags) this.flags = this.flags.filter((f) => f.name !== key);
2265
+ });
2266
+ }
2267
+ /**
2268
+ * Sign-out: delete the signed-out user's slot and drop the held values;
2269
+ * later lookups serve the `anon` slot, then the defaults.
2270
+ */
2271
+ clearForSignOut(signedOutUser) {
2272
+ this.generation += 1;
2273
+ this.config = null;
2274
+ this.flags = null;
2275
+ this.viewFor = null;
2276
+ if (!signedOutUser) return;
2277
+ const doomed = slotKey(signedOutUser);
2278
+ if (this.record.slots[doomed]) {
2279
+ delete this.record.slots[doomed];
2280
+ this.persist();
2281
+ }
2282
+ }
2283
+ /** Resolves when every write so far reached storage. */
2284
+ flushed() {
2285
+ return this.writes;
2286
+ }
2287
+ /**
2288
+ * Before a single-flag change to the current user's slot: when the slot
2289
+ * holds no flag list and the served list is the signed-out one, the
2290
+ * user's slot starts from that list, so after a relaunch the user reads
2291
+ * the same flags the change was made on.
2292
+ */
2293
+ adoptHeldFlags(slot, held) {
2294
+ if (held && !slot.flags && this.flags) slot.flags = [...this.flags];
2295
+ }
2296
+ defaultFor(key) {
2297
+ if (!Object.prototype.hasOwnProperty.call(this.defaults, key)) return void 0;
2298
+ const v = this.defaults[key];
2299
+ return v === null ? void 0 : v;
2300
+ }
2301
+ /**
2302
+ * `held` is `true` when the slot written is the one the current identity
2303
+ * reads, so the served values change with it.
2304
+ */
2305
+ write(ticket, targetOverride, apply) {
2306
+ const current = slotKey(this.currentUser());
2307
+ if (ticket.generation !== this.generation || slotKey(ticket.requestedFor) !== current) return;
2308
+ this.ensureView();
2309
+ const target = targetOverride ?? current;
2310
+ const existing = this.record.slots[target];
2311
+ const slot = existing ? {
2312
+ saved_at: existing.saved_at,
2313
+ ...existing.config ? { config: existing.config } : {},
2314
+ ...existing.flags ? { flags: [...existing.flags] } : {}
2315
+ } : { saved_at: 0 };
2316
+ apply(slot, target === current);
2317
+ if (!slot.config && !slot.flags) {
2318
+ if (!existing) return;
2319
+ delete this.record.slots[target];
2320
+ } else {
2321
+ slot.saved_at = Date.now();
2322
+ this.record.slots[target] = slot;
2323
+ this.prune(target);
2324
+ }
2325
+ this.persist();
2326
+ if (target !== current) this.viewFor = null;
2327
+ }
2328
+ /** Serve the current identity's own slot, the `anon` slot for the rest. */
2329
+ ensureView() {
2330
+ const current = slotKey(this.currentUser());
2331
+ if (this.viewFor === current) return;
2332
+ const own = this.record.slots[current];
2333
+ const anon = current === ANON_SLOT ? void 0 : this.record.slots[ANON_SLOT];
2334
+ const config = own?.config ?? anon?.config;
2335
+ const flags = own?.flags ?? anon?.flags;
2336
+ this.config = config ?? null;
2337
+ this.flags = flags ? [...flags] : null;
2338
+ this.viewFor = current;
2339
+ }
2340
+ /**
2341
+ * Keep the MAX_SLOTS most recently written user slots; `keep` and the
2342
+ * signed-out slot (read before sign-in and for every user's gaps) stay.
2343
+ */
2344
+ prune(keep) {
2345
+ const keys = Object.keys(this.record.slots).filter((k) => k !== ANON_SLOT);
2346
+ while (keys.length > MAX_SLOTS) {
2347
+ let oldest = null;
2348
+ for (const k of keys) {
2349
+ if (k === keep) continue;
2350
+ if (oldest === null || this.record.slots[k].saved_at < this.record.slots[oldest].saved_at) oldest = k;
2351
+ }
2352
+ if (oldest === null) break;
2353
+ delete this.record.slots[oldest];
2354
+ keys.splice(keys.indexOf(oldest), 1);
2355
+ }
2356
+ }
2357
+ persist() {
2358
+ const text = Object.keys(this.record.slots).length === 0 ? null : JSON.stringify(this.record);
2359
+ this.writes = this.writes.then(() => text === null ? this.store.delete(this.key) : this.store.write(this.key, text)).catch(() => {});
2360
+ }
2361
+ };
2362
+
1827
2363
  //#endregion
1828
2364
  //#region src/client-info.ts
1829
2365
  /**
@@ -1924,6 +2460,7 @@ var CollectionSubscription = class {
1924
2460
  return openStream({
1925
2461
  url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/collections/${pathSegment(this.name)}${qs}`,
1926
2462
  authHeaders: this.ctx.authHeaders,
2463
+ connectTimeoutMs: this.ctx.connectTimeoutMs,
1927
2464
  onChange: cb,
1928
2465
  options,
1929
2466
  parse: parseRowChangeFrame
@@ -1948,6 +2485,7 @@ var ConversationSubscription = class {
1948
2485
  return openStream({
1949
2486
  url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/conversations/${pathSegment(this.conversationId)}`,
1950
2487
  authHeaders: this.ctx.authHeaders,
2488
+ connectTimeoutMs: this.ctx.connectTimeoutMs,
1951
2489
  onChange: cb,
1952
2490
  options,
1953
2491
  parse: parseRowChangeFrame
@@ -1972,6 +2510,7 @@ var GamificationSubscription = class {
1972
2510
  return openStream({
1973
2511
  url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/gamification`,
1974
2512
  authHeaders: this.ctx.authHeaders,
2513
+ connectTimeoutMs: this.ctx.connectTimeoutMs,
1975
2514
  onChange: cb,
1976
2515
  options,
1977
2516
  parse: parseGamificationFrame
@@ -2006,6 +2545,7 @@ function openStream(args) {
2006
2545
  closeActive = connectOnce({
2007
2546
  url: args.url,
2008
2547
  headers,
2548
+ connectTimeoutMs: args.connectTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
2009
2549
  onFrame: (frame) => {
2010
2550
  const ev = args.parse(frame);
2011
2551
  if (ev !== null) args.onChange(ev);
@@ -2076,8 +2616,13 @@ function connectXhr(args, XHR) {
2076
2616
  xhr.open("GET", args.url, true);
2077
2617
  for (const [k, v] of Object.entries(args.headers)) xhr.setRequestHeader(k, v);
2078
2618
  xhr.setRequestHeader("Accept", "text/event-stream");
2619
+ const connectTimer = setTimeout(() => {
2620
+ if (aborted || xhr.readyState >= 2) return;
2621
+ args.onError(/* @__PURE__ */ new Error(`realtime connection timed out after ${args.connectTimeoutMs} ms`));
2622
+ }, args.connectTimeoutMs);
2079
2623
  xhr.onreadystatechange = () => {
2080
2624
  if (aborted) return;
2625
+ if (xhr.readyState >= 2) clearTimeout(connectTimer);
2081
2626
  if (xhr.readyState === 3) {
2082
2627
  if (xhr.status >= 400) return;
2083
2628
  drain();
@@ -2101,10 +2646,12 @@ function connectXhr(args, XHR) {
2101
2646
  try {
2102
2647
  xhr.send();
2103
2648
  } catch (err) {
2649
+ clearTimeout(connectTimer);
2104
2650
  args.onError(err);
2105
2651
  }
2106
2652
  return () => {
2107
2653
  aborted = true;
2654
+ clearTimeout(connectTimer);
2108
2655
  try {
2109
2656
  xhr.abort();
2110
2657
  } catch {}
@@ -2119,14 +2666,14 @@ function connectFetchStream(args) {
2119
2666
  let aborted = false;
2120
2667
  (async () => {
2121
2668
  try {
2122
- const resp = await fetch(args.url, {
2669
+ const resp = await raceDeadline(fetch(args.url, {
2123
2670
  method: "GET",
2124
2671
  headers: {
2125
2672
  ...args.headers,
2126
2673
  Accept: "text/event-stream"
2127
2674
  },
2128
2675
  signal: controller.signal
2129
- });
2676
+ }), args.connectTimeoutMs, controller, (ms) => /* @__PURE__ */ new Error(`realtime connection timed out after ${ms} ms`));
2130
2677
  if (!resp.ok || !resp.body) throw new Error(`realtime connect failed: HTTP ${resp.status}`);
2131
2678
  const reader = resp.body.getReader();
2132
2679
  const decoder = new TextDecoder();
@@ -2206,7 +2753,7 @@ function delay(ms) {
2206
2753
  //#endregion
2207
2754
  //#region src/index.ts
2208
2755
  /** SDK semver — burned in at build time. Bump alongside package.json. */
2209
- const SDK_VERSION = "4.1.0";
2756
+ const SDK_VERSION = "4.2.1";
2210
2757
  /**
2211
2758
  * Default host that serves deployed functions. `Amba.functions.invoke(name)`
2212
2759
  * targets `https://<projectId>.<DEFAULT_FUNCTIONS_HOST>/<name>` unless a
@@ -2250,6 +2797,62 @@ const authSubscribers = /* @__PURE__ */ new Set();
2250
2797
  * start a new session.
2251
2798
  */
2252
2799
  const experimentReporter = new ExperimentAssignmentReporter();
2800
+ /**
2801
+ * `configDefaults` from the latest `configure()` call, recorded before
2802
+ * configure reads the AsyncStorage cache. `config.getCached` and
2803
+ * `flags.isEnabled` serve them on a first render that runs while
2804
+ * `configure()` is still in flight.
2805
+ */
2806
+ let _pendingConfigDefaults = null;
2807
+ /** Validate `AmbaConfig.configDefaults`: absent, or a plain object. */
2808
+ function readConfigDefaults(value) {
2809
+ if (value === void 0 || value === null) return null;
2810
+ if (typeof value !== "object" || Array.isArray(value)) throw new AmbaApiError("INVALID_CONFIG", "Amba.configure: configDefaults must be an object of config key to value");
2811
+ return value;
2812
+ }
2813
+ /** The pending `configDefaults` entry for `key`, ignoring inherited properties. */
2814
+ function pendingDefault(key) {
2815
+ const defaults = _pendingConfigDefaults;
2816
+ if (!defaults || !Object.prototype.hasOwnProperty.call(defaults, key)) return;
2817
+ const value = defaults[key];
2818
+ return value === null ? void 0 : value;
2819
+ }
2820
+ /** `"array"` for arrays, else `typeof` — the JSON kind of a value. */
2821
+ function jsonKind(value) {
2822
+ return Array.isArray(value) ? "array" : typeof value;
2823
+ }
2824
+ function configGetCached(key, defaultValue) {
2825
+ if (state) refreshOnFirstRead(state, "config");
2826
+ const raw = state ? state.remoteValues.configValue(key) : pendingDefault(key);
2827
+ if (raw === void 0 || raw === null) return defaultValue;
2828
+ if (defaultValue !== void 0 && defaultValue !== null && jsonKind(raw) !== jsonKind(defaultValue)) return defaultValue;
2829
+ return raw;
2830
+ }
2831
+ /** `Amba.flags.isEnabled`. */
2832
+ function flagsIsEnabled(key, defaultValue = false) {
2833
+ if (state) refreshOnFirstRead(state, "flags");
2834
+ const held = state ? state.remoteValues.flagEnabled(key) : pendingDefault(key);
2835
+ return typeof held === "boolean" ? held : defaultValue;
2836
+ }
2837
+ /** `Amba.flags.getCached`. */
2838
+ function flagsGetCached(key) {
2839
+ if (!state) return null;
2840
+ refreshOnFirstRead(state, "flags");
2841
+ return state.remoteValues.flag(key);
2842
+ }
2843
+ /**
2844
+ * The first on-device read of config (or flags) for each identity after
2845
+ * `configure()` starts one background fetch that replaces the cached values:
2846
+ * once at launch, and once more after a sign-in or sign-out changes who is
2847
+ * reading. Apps that never read cached values make no extra request. A
2848
+ * failure (offline) leaves the cached values in place.
2849
+ */
2850
+ function refreshOnFirstRead(s, kind) {
2851
+ const identity = s.identity.getAppUserId() ?? "";
2852
+ if (s.refreshedFor[kind] === identity) return;
2853
+ s.refreshedFor[kind] = identity;
2854
+ Promise.resolve().then(() => kind === "config" ? Amba.config.fetch() : Amba.flags.fetch()).catch(() => {});
2855
+ }
2253
2856
  function snapshotSession(user) {
2254
2857
  if (!user) return null;
2255
2858
  return {
@@ -2761,9 +3364,28 @@ function offlineStore(s) {
2761
3364
  };
2762
3365
  }
2763
3366
  /**
3367
+ * Whether a live `track` (outbox on) carries its idempotency key:
3368
+ * engagement events whose properties the keyed path takes. Telemetry stays
3369
+ * on the unkeyed hot path, which writes no idempotency record and answers a
3370
+ * failed telemetry insert with 200; its replays from the outbox are keyed.
3371
+ */
3372
+ function keyedLiveTrack(properties, telemetry) {
3373
+ return telemetry !== true && keyableProperties(properties);
3374
+ }
3375
+ /**
3376
+ * A JSON copy of `properties`: exactly what a request body serializes, and
3377
+ * immune to the app changing the object after `track()` returns.
3378
+ */
3379
+ function snapshotProperties(properties) {
3380
+ if (properties === void 0 || properties === null) return void 0;
3381
+ const text = JSON.stringify(properties);
3382
+ return text === void 0 ? void 0 : JSON.parse(text);
3383
+ }
3384
+ /**
2764
3385
  * Replay one queued mutation against the live API, forwarding the op's
2765
- * idempotency key on collection inserts so a write that landed before the
2766
- * connection dropped is never double-applied (API migration 056).
3386
+ * idempotency key on collection inserts and tracked events so a write that
3387
+ * landed before the connection dropped is never double-applied (API
3388
+ * migration 056 for collections, `client_event_idempotency` for events).
2767
3389
  */
2768
3390
  async function replayOutboxOp(s, op) {
2769
3391
  switch (op.kind) {
@@ -2785,8 +3407,9 @@ async function replayOutboxOp(s, op) {
2785
3407
  event: op.event,
2786
3408
  properties: op.properties ?? void 0,
2787
3409
  anonymous_id: s.identity.getAnonymousId(),
2788
- timestamp: op.occurredAt ?? (/* @__PURE__ */ new Date()).toISOString(),
2789
- ...op.telemetry ? { telemetry: true } : {}
3410
+ timestamp: op.occurredAt ?? op.enqueuedAt,
3411
+ ...op.telemetry ? { telemetry: true } : {},
3412
+ ...keyableProperties(op.properties) ? { idempotency_key: op.idempotencyKey } : {}
2790
3413
  });
2791
3414
  } catch (err) {
2792
3415
  const code = err?.code;
@@ -2868,10 +3491,13 @@ const Amba = {
2868
3491
  if (!Number.isInteger(flushIntervalMs) || flushIntervalMs < 0) throw new Error("Amba.configure: events.flushIntervalMs must be a non-negative integer");
2869
3492
  if (!Number.isInteger(maxQueue) || maxQueue < 1) throw new Error("Amba.configure: events.maxQueue must be positive");
2870
3493
  }
3494
+ const deadlines = resolveRequestDeadlines(config);
2871
3495
  const tokenStorage = config.tokenStorage ?? "secure";
2872
3496
  if (!TOKEN_STORAGE_MODES.includes(tokenStorage)) throw new Error("Amba.configure: tokenStorage must be \"secure\", \"memory\" or \"asyncStorage\"");
2873
3497
  const secureStorage = config.secureStorage;
2874
3498
  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");
3499
+ const configDefaults = readConfigDefaults(config.configDefaults);
3500
+ _pendingConfigDefaults = configDefaults;
2875
3501
  const storagePrefix = config.storagePrefix ?? "amba";
2876
3502
  const persistence = new AsyncStoragePersistence(storagePrefix);
2877
3503
  if (config.events?.batching) await persistence.assertDurable();
@@ -2879,6 +3505,8 @@ const Amba = {
2879
3505
  const vault = await resolveTokenVault(tokenStorage, storagePrefix, secureStorage);
2880
3506
  const identity = await IdentityStore.create(persistence, vault);
2881
3507
  const baseUrl = (config.apiUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
3508
+ const remoteValues = new RemoteValuesCache(persistence, recordKey(baseUrl, config.clientKey), configDefaults ?? {}, () => identity.getAppUserId());
3509
+ await remoteValues.load();
2882
3510
  const sdkVersionHeader = `amba-sdk/${SDK_VERSION} ${`amba-react-native/${SDK_VERSION}`}`;
2883
3511
  const http = new AmbaHttpClient({
2884
3512
  apiKey: config.clientKey,
@@ -2889,6 +3517,8 @@ const Amba = {
2889
3517
  deviceRegion: detectDeviceRegion(),
2890
3518
  deviceOs: detectDeviceOs(),
2891
3519
  identity,
3520
+ requestTimeoutMs: deadlines.requestMs,
3521
+ longRequestTimeoutMs: deadlines.longMs,
2892
3522
  ensureSession: config.autoAnonymousSession === false ? void 0 : async (path) => {
2893
3523
  if (pathSkipsLazySession(path)) return;
2894
3524
  await ensureAnonymousSession(getState());
@@ -2912,6 +3542,11 @@ const Amba = {
2912
3542
  resolvedFunctionsProjectId: null,
2913
3543
  functionsProjectIdInflight: null,
2914
3544
  persistence,
3545
+ remoteValues,
3546
+ refreshedFor: {
3547
+ config: null,
3548
+ flags: null
3549
+ },
2915
3550
  offlineEnabled: false,
2916
3551
  offlineQueue: null,
2917
3552
  eventQueue: null
@@ -2964,12 +3599,14 @@ const Amba = {
2964
3599
  },
2965
3600
  collection: (name) => new CollectionSubscription({
2966
3601
  baseUrl: getState().baseUrl,
2967
- authHeaders: realtimeAuthHeaders
3602
+ authHeaders: realtimeAuthHeaders,
3603
+ connectTimeoutMs: getState().http.requestTimeoutMs
2968
3604
  }, name),
2969
3605
  get gamification() {
2970
3606
  return new GamificationSubscription({
2971
3607
  baseUrl: getState().baseUrl,
2972
- authHeaders: realtimeAuthHeaders
3608
+ authHeaders: realtimeAuthHeaders,
3609
+ connectTimeoutMs: getState().http.requestTimeoutMs
2973
3610
  });
2974
3611
  },
2975
3612
  events: {
@@ -2977,13 +3614,16 @@ const Amba = {
2977
3614
  const s = getState();
2978
3615
  if (s.eventQueue && !options?.immediate) return s.eventQueue.enqueue(event, properties, options?.telemetry === true);
2979
3616
  const occurredAt = (/* @__PURE__ */ new Date()).toISOString();
3617
+ const props = s.offlineEnabled ? snapshotProperties(properties) : properties;
2980
3618
  const body = {
2981
3619
  event,
2982
- properties: properties ?? void 0,
3620
+ properties: props ?? void 0,
2983
3621
  anonymous_id: s.identity.getAnonymousId(),
2984
3622
  timestamp: occurredAt
2985
3623
  };
2986
3624
  if (options?.telemetry) body["telemetry"] = true;
3625
+ const idempotencyKey = s.offlineEnabled ? newIdempotencyKey() : void 0;
3626
+ if (idempotencyKey && keyedLiveTrack(props, options?.telemetry)) body["idempotency_key"] = idempotencyKey;
2987
3627
  const outboxTicket = s.offlineEnabled ? getOfflineQueue(s).ticket() : void 0;
2988
3628
  try {
2989
3629
  await s.http.post(P.events, body);
@@ -2992,9 +3632,10 @@ const Amba = {
2992
3632
  await getOfflineQueue(s).enqueue({
2993
3633
  kind: "track",
2994
3634
  event,
2995
- properties,
3635
+ properties: props,
2996
3636
  telemetry: options?.telemetry,
2997
- occurredAt
3637
+ occurredAt,
3638
+ idempotencyKey
2998
3639
  }, outboxTicket);
2999
3640
  return;
3000
3641
  }
@@ -3162,7 +3803,10 @@ const Amba = {
3162
3803
  } catch (err) {
3163
3804
  if (typeof console !== "undefined") console.warn("[amba] signOut server call failed", err);
3164
3805
  }
3806
+ const signedOutUser = s.identity.getAppUserId();
3165
3807
  await s.identity.clearSession(rotateAnonymousId);
3808
+ s.remoteValues.clearForSignOut(signedOutUser);
3809
+ await s.remoteValues.flushed();
3166
3810
  try {
3167
3811
  await getOfflineQueue(s).purge();
3168
3812
  } catch (err) {
@@ -3453,21 +4097,25 @@ const Amba = {
3453
4097
  await getState().http.delete(`${P.storage}/${encodePathSegment(assetId)}`);
3454
4098
  },
3455
4099
  download: async (assetId) => {
3456
- const assetEnv = await getState().http.get(`${P.storage}/${encodePathSegment(assetId)}`);
4100
+ const s = getState();
4101
+ const assetEnv = await s.http.get(`${P.storage}/${encodePathSegment(assetId)}`);
3457
4102
  const cdnUrl = assetEnv.data.url ?? assetEnv.data.storage_url ?? "";
3458
4103
  if (!cdnUrl) throw new AmbaApiError("NOT_FOUND", "asset has no resolvable CDN url");
3459
- let response;
3460
- try {
3461
- response = await (globalThis.fetch ?? fetch)(cdnUrl, { method: "GET" });
3462
- } catch (err) {
3463
- throw new AmbaApiError("NETWORK_ERROR", `Network error: ${err instanceof Error ? err.message : String(err)}`, err);
3464
- }
3465
- if (!response.ok) {
3466
- const text = await response.text();
3467
- const ct = response.headers.get("content-type") ?? "";
3468
- throw mapHttpError(response, text, ct.includes("application/json"));
3469
- }
3470
- return response.blob();
4104
+ const sizeBytes = assetEnv.data.size_bytes;
4105
+ const { response, blob, text } = await s.http.send(cdnUrl, { method: "GET" }, async (res) => res.ok ? {
4106
+ response: res,
4107
+ blob: await res.blob(),
4108
+ text: ""
4109
+ } : {
4110
+ response: res,
4111
+ blob: null,
4112
+ text: await res.text()
4113
+ }, {
4114
+ timeoutMs: s.http.longRequestTimeoutMs,
4115
+ responseBytes: typeof sizeBytes === "number" ? sizeBytes : void 0
4116
+ });
4117
+ if (!blob) throw mapHttpError(response, text, (response.headers.get("content-type") ?? "").includes("application/json"));
4118
+ return blob;
3471
4119
  }
3472
4120
  },
3473
4121
  push: {
@@ -3515,27 +4163,30 @@ const Amba = {
3515
4163
  functions: { invoke: async (name, body, options) => {
3516
4164
  const s = getState();
3517
4165
  if (!name || name.trim() === "") throw new AmbaApiError("INVALID_ARGUMENT", "Amba.functions.invoke: a function name is required.");
4166
+ let timeoutMs;
4167
+ try {
4168
+ timeoutMs = readTimeout("Amba.functions.invoke: timeoutMs", options?.timeoutMs, s.http.longRequestTimeoutMs);
4169
+ } catch (err) {
4170
+ throw new AmbaApiError("INVALID_ARGUMENT", err.message);
4171
+ }
3518
4172
  const url = await resolveFunctionUrl(s, name);
3519
4173
  const method = options?.method ?? "POST";
3520
4174
  const headers = s.http.buildHeaders(options?.headers);
3521
4175
  let requestBody;
3522
4176
  if (body !== void 0 && method !== "GET") requestBody = JSON.stringify(body);
3523
4177
  else delete headers["Content-Type"];
3524
- let response;
3525
- try {
3526
- response = await (globalThis.fetch ?? fetch)(url, {
3527
- method,
3528
- headers,
3529
- body: requestBody
3530
- });
3531
- } catch (err) {
3532
- throw new AmbaApiError("NETWORK_ERROR", `Amba.functions.invoke("${name}") network error: ${err instanceof Error ? err.message : String(err)}`, err);
3533
- }
3534
- const text = await response.text();
3535
- if (!response.ok) {
3536
- const ct = response.headers.get("content-type") ?? "";
3537
- throw mapHttpError(response, text, ct.includes("application/json"));
3538
- }
4178
+ const { response, text } = await s.http.send(url, {
4179
+ method,
4180
+ headers,
4181
+ body: requestBody
4182
+ }, async (res) => ({
4183
+ response: res,
4184
+ text: await res.text()
4185
+ }), {
4186
+ timeoutMs,
4187
+ label: `Amba.functions.invoke("${name}") network error`
4188
+ });
4189
+ if (!response.ok) throw mapHttpError(response, text, (response.headers.get("content-type") ?? "").includes("application/json"));
3539
4190
  if (!text) return void 0;
3540
4191
  try {
3541
4192
  return JSON.parse(text);
@@ -3543,54 +4194,63 @@ const Amba = {
3543
4194
  return text;
3544
4195
  }
3545
4196
  } },
3546
- config: { fetch: async () => {
3547
- const s = getState();
3548
- const url = s.http.url(P.config);
3549
- const headers = s.http.buildHeaders();
3550
- let response;
3551
- try {
3552
- response = await (globalThis.fetch ?? fetch)(url, {
4197
+ config: {
4198
+ fetch: async () => {
4199
+ const s = getState();
4200
+ const ticket = s.remoteValues.ticket();
4201
+ const url = s.http.url(P.config);
4202
+ const headers = s.http.buildHeaders();
4203
+ const { response, text } = await s.http.send(url, {
3553
4204
  method: "GET",
3554
4205
  headers
3555
- });
3556
- } catch (err) {
3557
- throw new AmbaApiError("NETWORK_ERROR", `Network error: ${err instanceof Error ? err.message : String(err)}`, err);
3558
- }
3559
- const text = await response.text();
3560
- if (!response.ok) {
3561
- const ct = response.headers.get("content-type") ?? "";
3562
- throw mapHttpError(response, text, ct.includes("application/json"));
3563
- }
3564
- let parsed = {};
3565
- if (text) try {
3566
- parsed = JSON.parse(text);
3567
- } catch {
3568
- parsed = {};
3569
- }
3570
- const etag = response.headers.get("etag");
3571
- let version = null;
3572
- if (etag) {
3573
- const cleaned = etag.replace(/^"|"$/g, "");
3574
- const m = cleaned.match(/^(v\d+)/);
3575
- version = m ? m[1] ?? null : cleaned || null;
3576
- }
3577
- return {
3578
- version,
3579
- values: parsed.data ?? {}
3580
- };
3581
- } },
4206
+ }, async (res) => ({
4207
+ response: res,
4208
+ text: await res.text()
4209
+ }));
4210
+ if (!response.ok) throw mapHttpError(response, text, (response.headers.get("content-type") ?? "").includes("application/json"));
4211
+ let parsed = {};
4212
+ if (text) try {
4213
+ parsed = JSON.parse(text);
4214
+ } catch {
4215
+ parsed = {};
4216
+ }
4217
+ const etag = response.headers.get("etag");
4218
+ let version = null;
4219
+ if (etag) {
4220
+ const cleaned = etag.replace(/^"|"$/g, "");
4221
+ const m = cleaned.match(/^(v\d+)/);
4222
+ version = m ? m[1] ?? null : cleaned || null;
4223
+ }
4224
+ return s.remoteValues.storeConfig(ticket, {
4225
+ version,
4226
+ values: parsed.data ?? {}
4227
+ }, etag);
4228
+ },
4229
+ getCached: configGetCached
4230
+ },
3582
4231
  flags: {
3583
4232
  fetch: async () => {
3584
- return (await getState().http.get(P.flags)).data;
4233
+ const s = getState();
4234
+ const ticket = s.remoteValues.ticket();
4235
+ const env = await s.http.get(P.flags);
4236
+ return s.remoteValues.storeFlags(ticket, env.data);
3585
4237
  },
3586
4238
  get: async (key) => {
4239
+ const s = getState();
4240
+ const ticket = s.remoteValues.ticket();
3587
4241
  try {
3588
- return (await getState().http.get(`${P.flags}/${encodePathSegment(key)}`)).data;
4242
+ const env = await s.http.get(`${P.flags}/${encodePathSegment(key)}`);
4243
+ return s.remoteValues.upsertFlag(ticket, env.data);
3589
4244
  } catch (err) {
3590
- if (err instanceof AmbaApiError && err.code === "NOT_FOUND") return null;
4245
+ if (err instanceof AmbaApiError && err.code === "NOT_FOUND") {
4246
+ s.remoteValues.removeFlag(ticket, key);
4247
+ return null;
4248
+ }
3591
4249
  throw err;
3592
4250
  }
3593
- }
4251
+ },
4252
+ getCached: flagsGetCached,
4253
+ isEnabled: flagsIsEnabled
3594
4254
  },
3595
4255
  experiments: {
3596
4256
  getVariant: async (key) => {
@@ -3770,7 +4430,8 @@ const Amba = {
3770
4430
  messaging: {
3771
4431
  conversation: (conversationId) => new ConversationSubscription({
3772
4432
  baseUrl: getState().baseUrl,
3773
- authHeaders: realtimeAuthHeaders
4433
+ authHeaders: realtimeAuthHeaders,
4434
+ connectTimeoutMs: getState().http.requestTimeoutMs
3774
4435
  }, conversationId),
3775
4436
  createConversation: async (request) => {
3776
4437
  return (await getState().http.post(`${P.messaging}/conversations`, request)).data;