@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.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.
@@ -2208,6 +2454,7 @@ var CollectionSubscription = class {
2208
2454
  return openStream({
2209
2455
  url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/collections/${pathSegment(this.name)}${qs}`,
2210
2456
  authHeaders: this.ctx.authHeaders,
2457
+ connectTimeoutMs: this.ctx.connectTimeoutMs,
2211
2458
  onChange: cb,
2212
2459
  options,
2213
2460
  parse: parseRowChangeFrame
@@ -2232,6 +2479,7 @@ var ConversationSubscription = class {
2232
2479
  return openStream({
2233
2480
  url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/conversations/${pathSegment(this.conversationId)}`,
2234
2481
  authHeaders: this.ctx.authHeaders,
2482
+ connectTimeoutMs: this.ctx.connectTimeoutMs,
2235
2483
  onChange: cb,
2236
2484
  options,
2237
2485
  parse: parseRowChangeFrame
@@ -2256,6 +2504,7 @@ var GamificationSubscription = class {
2256
2504
  return openStream({
2257
2505
  url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/gamification`,
2258
2506
  authHeaders: this.ctx.authHeaders,
2507
+ connectTimeoutMs: this.ctx.connectTimeoutMs,
2259
2508
  onChange: cb,
2260
2509
  options,
2261
2510
  parse: parseGamificationFrame
@@ -2290,6 +2539,7 @@ function openStream(args) {
2290
2539
  closeActive = connectOnce({
2291
2540
  url: args.url,
2292
2541
  headers,
2542
+ connectTimeoutMs: args.connectTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
2293
2543
  onFrame: (frame) => {
2294
2544
  const ev = args.parse(frame);
2295
2545
  if (ev !== null) args.onChange(ev);
@@ -2360,8 +2610,13 @@ function connectXhr(args, XHR) {
2360
2610
  xhr.open("GET", args.url, true);
2361
2611
  for (const [k, v] of Object.entries(args.headers)) xhr.setRequestHeader(k, v);
2362
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);
2363
2617
  xhr.onreadystatechange = () => {
2364
2618
  if (aborted) return;
2619
+ if (xhr.readyState >= 2) clearTimeout(connectTimer);
2365
2620
  if (xhr.readyState === 3) {
2366
2621
  if (xhr.status >= 400) return;
2367
2622
  drain();
@@ -2385,10 +2640,12 @@ function connectXhr(args, XHR) {
2385
2640
  try {
2386
2641
  xhr.send();
2387
2642
  } catch (err) {
2643
+ clearTimeout(connectTimer);
2388
2644
  args.onError(err);
2389
2645
  }
2390
2646
  return () => {
2391
2647
  aborted = true;
2648
+ clearTimeout(connectTimer);
2392
2649
  try {
2393
2650
  xhr.abort();
2394
2651
  } catch {}
@@ -2403,14 +2660,14 @@ function connectFetchStream(args) {
2403
2660
  let aborted = false;
2404
2661
  (async () => {
2405
2662
  try {
2406
- const resp = await fetch(args.url, {
2663
+ const resp = await raceDeadline(fetch(args.url, {
2407
2664
  method: "GET",
2408
2665
  headers: {
2409
2666
  ...args.headers,
2410
2667
  Accept: "text/event-stream"
2411
2668
  },
2412
2669
  signal: controller.signal
2413
- });
2670
+ }), args.connectTimeoutMs, controller, (ms) => /* @__PURE__ */ new Error(`realtime connection timed out after ${ms} ms`));
2414
2671
  if (!resp.ok || !resp.body) throw new Error(`realtime connect failed: HTTP ${resp.status}`);
2415
2672
  const reader = resp.body.getReader();
2416
2673
  const decoder = new TextDecoder();
@@ -2490,7 +2747,7 @@ function delay(ms) {
2490
2747
  //#endregion
2491
2748
  //#region src/index.ts
2492
2749
  /** SDK semver — burned in at build time. Bump alongside package.json. */
2493
- const SDK_VERSION = "4.2.0";
2750
+ const SDK_VERSION = "4.2.1";
2494
2751
  /**
2495
2752
  * Default host that serves deployed functions. `Amba.functions.invoke(name)`
2496
2753
  * targets `https://<projectId>.<DEFAULT_FUNCTIONS_HOST>/<name>` unless a
@@ -3101,9 +3358,28 @@ function offlineStore(s) {
3101
3358
  };
3102
3359
  }
3103
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
+ /**
3104
3379
  * Replay one queued mutation against the live API, forwarding the op's
3105
- * idempotency key on collection inserts so a write that landed before the
3106
- * 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).
3107
3383
  */
3108
3384
  async function replayOutboxOp(s, op) {
3109
3385
  switch (op.kind) {
@@ -3125,8 +3401,9 @@ async function replayOutboxOp(s, op) {
3125
3401
  event: op.event,
3126
3402
  properties: op.properties ?? void 0,
3127
3403
  anonymous_id: s.identity.getAnonymousId(),
3128
- timestamp: op.occurredAt ?? (/* @__PURE__ */ new Date()).toISOString(),
3129
- ...op.telemetry ? { telemetry: true } : {}
3404
+ timestamp: op.occurredAt ?? op.enqueuedAt,
3405
+ ...op.telemetry ? { telemetry: true } : {},
3406
+ ...keyableProperties(op.properties) ? { idempotency_key: op.idempotencyKey } : {}
3130
3407
  });
3131
3408
  } catch (err) {
3132
3409
  const code = err?.code;
@@ -3208,6 +3485,7 @@ const Amba = {
3208
3485
  if (!Number.isInteger(flushIntervalMs) || flushIntervalMs < 0) throw new Error("Amba.configure: events.flushIntervalMs must be a non-negative integer");
3209
3486
  if (!Number.isInteger(maxQueue) || maxQueue < 1) throw new Error("Amba.configure: events.maxQueue must be positive");
3210
3487
  }
3488
+ const deadlines = resolveRequestDeadlines(config);
3211
3489
  const tokenStorage = config.tokenStorage ?? "secure";
3212
3490
  if (!TOKEN_STORAGE_MODES.includes(tokenStorage)) throw new Error("Amba.configure: tokenStorage must be \"secure\", \"memory\" or \"asyncStorage\"");
3213
3491
  const secureStorage = config.secureStorage;
@@ -3233,6 +3511,8 @@ const Amba = {
3233
3511
  deviceRegion: detectDeviceRegion(),
3234
3512
  deviceOs: detectDeviceOs(),
3235
3513
  identity,
3514
+ requestTimeoutMs: deadlines.requestMs,
3515
+ longRequestTimeoutMs: deadlines.longMs,
3236
3516
  ensureSession: config.autoAnonymousSession === false ? void 0 : async (path) => {
3237
3517
  if (pathSkipsLazySession(path)) return;
3238
3518
  await ensureAnonymousSession(getState());
@@ -3313,12 +3593,14 @@ const Amba = {
3313
3593
  },
3314
3594
  collection: (name) => new CollectionSubscription({
3315
3595
  baseUrl: getState().baseUrl,
3316
- authHeaders: realtimeAuthHeaders
3596
+ authHeaders: realtimeAuthHeaders,
3597
+ connectTimeoutMs: getState().http.requestTimeoutMs
3317
3598
  }, name),
3318
3599
  get gamification() {
3319
3600
  return new GamificationSubscription({
3320
3601
  baseUrl: getState().baseUrl,
3321
- authHeaders: realtimeAuthHeaders
3602
+ authHeaders: realtimeAuthHeaders,
3603
+ connectTimeoutMs: getState().http.requestTimeoutMs
3322
3604
  });
3323
3605
  },
3324
3606
  events: {
@@ -3326,13 +3608,16 @@ const Amba = {
3326
3608
  const s = getState();
3327
3609
  if (s.eventQueue && !options?.immediate) return s.eventQueue.enqueue(event, properties, options?.telemetry === true);
3328
3610
  const occurredAt = (/* @__PURE__ */ new Date()).toISOString();
3611
+ const props = s.offlineEnabled ? snapshotProperties(properties) : properties;
3329
3612
  const body = {
3330
3613
  event,
3331
- properties: properties ?? void 0,
3614
+ properties: props ?? void 0,
3332
3615
  anonymous_id: s.identity.getAnonymousId(),
3333
3616
  timestamp: occurredAt
3334
3617
  };
3335
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;
3336
3621
  const outboxTicket = s.offlineEnabled ? getOfflineQueue(s).ticket() : void 0;
3337
3622
  try {
3338
3623
  await s.http.post(P.events, body);
@@ -3341,9 +3626,10 @@ const Amba = {
3341
3626
  await getOfflineQueue(s).enqueue({
3342
3627
  kind: "track",
3343
3628
  event,
3344
- properties,
3629
+ properties: props,
3345
3630
  telemetry: options?.telemetry,
3346
- occurredAt
3631
+ occurredAt,
3632
+ idempotencyKey
3347
3633
  }, outboxTicket);
3348
3634
  return;
3349
3635
  }
@@ -3805,21 +4091,25 @@ const Amba = {
3805
4091
  await getState().http.delete(`${P.storage}/${encodePathSegment(assetId)}`);
3806
4092
  },
3807
4093
  download: async (assetId) => {
3808
- 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)}`);
3809
4096
  const cdnUrl = assetEnv.data.url ?? assetEnv.data.storage_url ?? "";
3810
4097
  if (!cdnUrl) throw new AmbaApiError("NOT_FOUND", "asset has no resolvable CDN url");
3811
- let response;
3812
- try {
3813
- response = await (globalThis.fetch ?? fetch)(cdnUrl, { method: "GET" });
3814
- } catch (err) {
3815
- throw new AmbaApiError("NETWORK_ERROR", `Network error: ${err instanceof Error ? err.message : String(err)}`, err);
3816
- }
3817
- if (!response.ok) {
3818
- const text = await response.text();
3819
- const ct = response.headers.get("content-type") ?? "";
3820
- throw mapHttpError(response, text, ct.includes("application/json"));
3821
- }
3822
- 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;
3823
4113
  }
3824
4114
  },
3825
4115
  push: {
@@ -3867,27 +4157,30 @@ const Amba = {
3867
4157
  functions: { invoke: async (name, body, options) => {
3868
4158
  const s = getState();
3869
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
+ }
3870
4166
  const url = await resolveFunctionUrl(s, name);
3871
4167
  const method = options?.method ?? "POST";
3872
4168
  const headers = s.http.buildHeaders(options?.headers);
3873
4169
  let requestBody;
3874
4170
  if (body !== void 0 && method !== "GET") requestBody = JSON.stringify(body);
3875
4171
  else delete headers["Content-Type"];
3876
- let response;
3877
- try {
3878
- response = await (globalThis.fetch ?? fetch)(url, {
3879
- method,
3880
- headers,
3881
- body: requestBody
3882
- });
3883
- } catch (err) {
3884
- throw new AmbaApiError("NETWORK_ERROR", `Amba.functions.invoke("${name}") network error: ${err instanceof Error ? err.message : String(err)}`, err);
3885
- }
3886
- const text = await response.text();
3887
- if (!response.ok) {
3888
- const ct = response.headers.get("content-type") ?? "";
3889
- throw mapHttpError(response, text, ct.includes("application/json"));
3890
- }
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"));
3891
4184
  if (!text) return void 0;
3892
4185
  try {
3893
4186
  return JSON.parse(text);
@@ -3901,20 +4194,14 @@ const Amba = {
3901
4194
  const ticket = s.remoteValues.ticket();
3902
4195
  const url = s.http.url(P.config);
3903
4196
  const headers = s.http.buildHeaders();
3904
- let response;
3905
- try {
3906
- response = await (globalThis.fetch ?? fetch)(url, {
3907
- method: "GET",
3908
- headers
3909
- });
3910
- } catch (err) {
3911
- throw new AmbaApiError("NETWORK_ERROR", `Network error: ${err instanceof Error ? err.message : String(err)}`, err);
3912
- }
3913
- const text = await response.text();
3914
- if (!response.ok) {
3915
- const ct = response.headers.get("content-type") ?? "";
3916
- throw mapHttpError(response, text, ct.includes("application/json"));
3917
- }
4197
+ const { response, text } = await s.http.send(url, {
4198
+ method: "GET",
4199
+ headers
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"));
3918
4205
  let parsed = {};
3919
4206
  if (text) try {
3920
4207
  parsed = JSON.parse(text);
@@ -4137,7 +4424,8 @@ const Amba = {
4137
4424
  messaging: {
4138
4425
  conversation: (conversationId) => new ConversationSubscription({
4139
4426
  baseUrl: getState().baseUrl,
4140
- authHeaders: realtimeAuthHeaders
4427
+ authHeaders: realtimeAuthHeaders,
4428
+ connectTimeoutMs: getState().http.requestTimeoutMs
4141
4429
  }, conversationId),
4142
4430
  createConversation: async (request) => {
4143
4431
  return (await getState().http.post(`${P.messaging}/conversations`, request)).data;