@layers/amba-react-native 4.2.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.
@@ -2214,6 +2460,7 @@ var CollectionSubscription = class {
2214
2460
  return openStream({
2215
2461
  url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/collections/${pathSegment(this.name)}${qs}`,
2216
2462
  authHeaders: this.ctx.authHeaders,
2463
+ connectTimeoutMs: this.ctx.connectTimeoutMs,
2217
2464
  onChange: cb,
2218
2465
  options,
2219
2466
  parse: parseRowChangeFrame
@@ -2238,6 +2485,7 @@ var ConversationSubscription = class {
2238
2485
  return openStream({
2239
2486
  url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/conversations/${pathSegment(this.conversationId)}`,
2240
2487
  authHeaders: this.ctx.authHeaders,
2488
+ connectTimeoutMs: this.ctx.connectTimeoutMs,
2241
2489
  onChange: cb,
2242
2490
  options,
2243
2491
  parse: parseRowChangeFrame
@@ -2262,6 +2510,7 @@ var GamificationSubscription = class {
2262
2510
  return openStream({
2263
2511
  url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/gamification`,
2264
2512
  authHeaders: this.ctx.authHeaders,
2513
+ connectTimeoutMs: this.ctx.connectTimeoutMs,
2265
2514
  onChange: cb,
2266
2515
  options,
2267
2516
  parse: parseGamificationFrame
@@ -2296,6 +2545,7 @@ function openStream(args) {
2296
2545
  closeActive = connectOnce({
2297
2546
  url: args.url,
2298
2547
  headers,
2548
+ connectTimeoutMs: args.connectTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
2299
2549
  onFrame: (frame) => {
2300
2550
  const ev = args.parse(frame);
2301
2551
  if (ev !== null) args.onChange(ev);
@@ -2366,8 +2616,13 @@ function connectXhr(args, XHR) {
2366
2616
  xhr.open("GET", args.url, true);
2367
2617
  for (const [k, v] of Object.entries(args.headers)) xhr.setRequestHeader(k, v);
2368
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);
2369
2623
  xhr.onreadystatechange = () => {
2370
2624
  if (aborted) return;
2625
+ if (xhr.readyState >= 2) clearTimeout(connectTimer);
2371
2626
  if (xhr.readyState === 3) {
2372
2627
  if (xhr.status >= 400) return;
2373
2628
  drain();
@@ -2391,10 +2646,12 @@ function connectXhr(args, XHR) {
2391
2646
  try {
2392
2647
  xhr.send();
2393
2648
  } catch (err) {
2649
+ clearTimeout(connectTimer);
2394
2650
  args.onError(err);
2395
2651
  }
2396
2652
  return () => {
2397
2653
  aborted = true;
2654
+ clearTimeout(connectTimer);
2398
2655
  try {
2399
2656
  xhr.abort();
2400
2657
  } catch {}
@@ -2409,14 +2666,14 @@ function connectFetchStream(args) {
2409
2666
  let aborted = false;
2410
2667
  (async () => {
2411
2668
  try {
2412
- const resp = await fetch(args.url, {
2669
+ const resp = await raceDeadline(fetch(args.url, {
2413
2670
  method: "GET",
2414
2671
  headers: {
2415
2672
  ...args.headers,
2416
2673
  Accept: "text/event-stream"
2417
2674
  },
2418
2675
  signal: controller.signal
2419
- });
2676
+ }), args.connectTimeoutMs, controller, (ms) => /* @__PURE__ */ new Error(`realtime connection timed out after ${ms} ms`));
2420
2677
  if (!resp.ok || !resp.body) throw new Error(`realtime connect failed: HTTP ${resp.status}`);
2421
2678
  const reader = resp.body.getReader();
2422
2679
  const decoder = new TextDecoder();
@@ -2496,7 +2753,7 @@ function delay(ms) {
2496
2753
  //#endregion
2497
2754
  //#region src/index.ts
2498
2755
  /** SDK semver — burned in at build time. Bump alongside package.json. */
2499
- const SDK_VERSION = "4.2.0";
2756
+ const SDK_VERSION = "4.2.1";
2500
2757
  /**
2501
2758
  * Default host that serves deployed functions. `Amba.functions.invoke(name)`
2502
2759
  * targets `https://<projectId>.<DEFAULT_FUNCTIONS_HOST>/<name>` unless a
@@ -3107,9 +3364,28 @@ function offlineStore(s) {
3107
3364
  };
3108
3365
  }
3109
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
+ /**
3110
3385
  * Replay one queued mutation against the live API, forwarding the op's
3111
- * idempotency key on collection inserts so a write that landed before the
3112
- * 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).
3113
3389
  */
3114
3390
  async function replayOutboxOp(s, op) {
3115
3391
  switch (op.kind) {
@@ -3131,8 +3407,9 @@ async function replayOutboxOp(s, op) {
3131
3407
  event: op.event,
3132
3408
  properties: op.properties ?? void 0,
3133
3409
  anonymous_id: s.identity.getAnonymousId(),
3134
- timestamp: op.occurredAt ?? (/* @__PURE__ */ new Date()).toISOString(),
3135
- ...op.telemetry ? { telemetry: true } : {}
3410
+ timestamp: op.occurredAt ?? op.enqueuedAt,
3411
+ ...op.telemetry ? { telemetry: true } : {},
3412
+ ...keyableProperties(op.properties) ? { idempotency_key: op.idempotencyKey } : {}
3136
3413
  });
3137
3414
  } catch (err) {
3138
3415
  const code = err?.code;
@@ -3214,6 +3491,7 @@ const Amba = {
3214
3491
  if (!Number.isInteger(flushIntervalMs) || flushIntervalMs < 0) throw new Error("Amba.configure: events.flushIntervalMs must be a non-negative integer");
3215
3492
  if (!Number.isInteger(maxQueue) || maxQueue < 1) throw new Error("Amba.configure: events.maxQueue must be positive");
3216
3493
  }
3494
+ const deadlines = resolveRequestDeadlines(config);
3217
3495
  const tokenStorage = config.tokenStorage ?? "secure";
3218
3496
  if (!TOKEN_STORAGE_MODES.includes(tokenStorage)) throw new Error("Amba.configure: tokenStorage must be \"secure\", \"memory\" or \"asyncStorage\"");
3219
3497
  const secureStorage = config.secureStorage;
@@ -3239,6 +3517,8 @@ const Amba = {
3239
3517
  deviceRegion: detectDeviceRegion(),
3240
3518
  deviceOs: detectDeviceOs(),
3241
3519
  identity,
3520
+ requestTimeoutMs: deadlines.requestMs,
3521
+ longRequestTimeoutMs: deadlines.longMs,
3242
3522
  ensureSession: config.autoAnonymousSession === false ? void 0 : async (path) => {
3243
3523
  if (pathSkipsLazySession(path)) return;
3244
3524
  await ensureAnonymousSession(getState());
@@ -3319,12 +3599,14 @@ const Amba = {
3319
3599
  },
3320
3600
  collection: (name) => new CollectionSubscription({
3321
3601
  baseUrl: getState().baseUrl,
3322
- authHeaders: realtimeAuthHeaders
3602
+ authHeaders: realtimeAuthHeaders,
3603
+ connectTimeoutMs: getState().http.requestTimeoutMs
3323
3604
  }, name),
3324
3605
  get gamification() {
3325
3606
  return new GamificationSubscription({
3326
3607
  baseUrl: getState().baseUrl,
3327
- authHeaders: realtimeAuthHeaders
3608
+ authHeaders: realtimeAuthHeaders,
3609
+ connectTimeoutMs: getState().http.requestTimeoutMs
3328
3610
  });
3329
3611
  },
3330
3612
  events: {
@@ -3332,13 +3614,16 @@ const Amba = {
3332
3614
  const s = getState();
3333
3615
  if (s.eventQueue && !options?.immediate) return s.eventQueue.enqueue(event, properties, options?.telemetry === true);
3334
3616
  const occurredAt = (/* @__PURE__ */ new Date()).toISOString();
3617
+ const props = s.offlineEnabled ? snapshotProperties(properties) : properties;
3335
3618
  const body = {
3336
3619
  event,
3337
- properties: properties ?? void 0,
3620
+ properties: props ?? void 0,
3338
3621
  anonymous_id: s.identity.getAnonymousId(),
3339
3622
  timestamp: occurredAt
3340
3623
  };
3341
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;
3342
3627
  const outboxTicket = s.offlineEnabled ? getOfflineQueue(s).ticket() : void 0;
3343
3628
  try {
3344
3629
  await s.http.post(P.events, body);
@@ -3347,9 +3632,10 @@ const Amba = {
3347
3632
  await getOfflineQueue(s).enqueue({
3348
3633
  kind: "track",
3349
3634
  event,
3350
- properties,
3635
+ properties: props,
3351
3636
  telemetry: options?.telemetry,
3352
- occurredAt
3637
+ occurredAt,
3638
+ idempotencyKey
3353
3639
  }, outboxTicket);
3354
3640
  return;
3355
3641
  }
@@ -3811,21 +4097,25 @@ const Amba = {
3811
4097
  await getState().http.delete(`${P.storage}/${encodePathSegment(assetId)}`);
3812
4098
  },
3813
4099
  download: async (assetId) => {
3814
- 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)}`);
3815
4102
  const cdnUrl = assetEnv.data.url ?? assetEnv.data.storage_url ?? "";
3816
4103
  if (!cdnUrl) throw new AmbaApiError("NOT_FOUND", "asset has no resolvable CDN url");
3817
- let response;
3818
- try {
3819
- response = await (globalThis.fetch ?? fetch)(cdnUrl, { method: "GET" });
3820
- } catch (err) {
3821
- throw new AmbaApiError("NETWORK_ERROR", `Network error: ${err instanceof Error ? err.message : String(err)}`, err);
3822
- }
3823
- if (!response.ok) {
3824
- const text = await response.text();
3825
- const ct = response.headers.get("content-type") ?? "";
3826
- throw mapHttpError(response, text, ct.includes("application/json"));
3827
- }
3828
- 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;
3829
4119
  }
3830
4120
  },
3831
4121
  push: {
@@ -3873,27 +4163,30 @@ const Amba = {
3873
4163
  functions: { invoke: async (name, body, options) => {
3874
4164
  const s = getState();
3875
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
+ }
3876
4172
  const url = await resolveFunctionUrl(s, name);
3877
4173
  const method = options?.method ?? "POST";
3878
4174
  const headers = s.http.buildHeaders(options?.headers);
3879
4175
  let requestBody;
3880
4176
  if (body !== void 0 && method !== "GET") requestBody = JSON.stringify(body);
3881
4177
  else delete headers["Content-Type"];
3882
- let response;
3883
- try {
3884
- response = await (globalThis.fetch ?? fetch)(url, {
3885
- method,
3886
- headers,
3887
- body: requestBody
3888
- });
3889
- } catch (err) {
3890
- throw new AmbaApiError("NETWORK_ERROR", `Amba.functions.invoke("${name}") network error: ${err instanceof Error ? err.message : String(err)}`, err);
3891
- }
3892
- const text = await response.text();
3893
- if (!response.ok) {
3894
- const ct = response.headers.get("content-type") ?? "";
3895
- throw mapHttpError(response, text, ct.includes("application/json"));
3896
- }
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"));
3897
4190
  if (!text) return void 0;
3898
4191
  try {
3899
4192
  return JSON.parse(text);
@@ -3907,20 +4200,14 @@ const Amba = {
3907
4200
  const ticket = s.remoteValues.ticket();
3908
4201
  const url = s.http.url(P.config);
3909
4202
  const headers = s.http.buildHeaders();
3910
- let response;
3911
- try {
3912
- response = await (globalThis.fetch ?? fetch)(url, {
3913
- method: "GET",
3914
- headers
3915
- });
3916
- } catch (err) {
3917
- throw new AmbaApiError("NETWORK_ERROR", `Network error: ${err instanceof Error ? err.message : String(err)}`, err);
3918
- }
3919
- const text = await response.text();
3920
- if (!response.ok) {
3921
- const ct = response.headers.get("content-type") ?? "";
3922
- throw mapHttpError(response, text, ct.includes("application/json"));
3923
- }
4203
+ const { response, text } = await s.http.send(url, {
4204
+ method: "GET",
4205
+ headers
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"));
3924
4211
  let parsed = {};
3925
4212
  if (text) try {
3926
4213
  parsed = JSON.parse(text);
@@ -4143,7 +4430,8 @@ const Amba = {
4143
4430
  messaging: {
4144
4431
  conversation: (conversationId) => new ConversationSubscription({
4145
4432
  baseUrl: getState().baseUrl,
4146
- authHeaders: realtimeAuthHeaders
4433
+ authHeaders: realtimeAuthHeaders,
4434
+ connectTimeoutMs: getState().http.requestTimeoutMs
4147
4435
  }, conversationId),
4148
4436
  createConversation: async (request) => {
4149
4437
  return (await getState().http.post(`${P.messaging}/conversations`, request)).data;