@layers/amba-react-native 4.2.0 → 4.2.2

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,12 +90,200 @@ 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. */
96
257
  const DEFAULT_BASE_URL = "https://api.amba.dev";
97
- /** Server codes for a session token the server no longer accepts. */
98
- const SESSION_REFUSED_CODES = new Set(["SESSION_REVOKED", "INVALID_SESSION_TOKEN"]);
258
+ /**
259
+ * Server codes for a session token the server no longer accepts.
260
+ * `INVALID_SESSION` is the code the auth routes that verify the session
261
+ * themselves (`/identifiers`, `/link`, `/unlink`) answer with.
262
+ */
263
+ const SESSION_REFUSED_CODES = new Set([
264
+ "SESSION_REVOKED",
265
+ "INVALID_SESSION_TOKEN",
266
+ "INVALID_SESSION"
267
+ ]);
268
+ /** True for a server error that refused the session token a call sent. */
269
+ function isSessionRefusedError(err) {
270
+ return err instanceof AmbaApiError && SESSION_REFUSED_CODES.has(err.code);
271
+ }
272
+ const AUTH_PATH_PREFIX = "/v1/client/auth/";
273
+ /**
274
+ * The auth routes that read the session from the `Authorization` header and
275
+ * mint nothing: a refused session on them recovers like it does on any
276
+ * other authenticated call. The other auth routes stay out of the
277
+ * recovery: `/refresh` is the recovery itself, `/logout` ends the session,
278
+ * the sign-in, sign-up, OTP and magic-link routes take credentials (a 401
279
+ * there is a credential error), and `/link` and `/unlink` carry the session
280
+ * in their body, so their callers recover and rebuild the body themselves.
281
+ */
282
+ const SESSION_HEADER_AUTH_PATHS = new Set([`${AUTH_PATH_PREFIX}me`, `${AUTH_PATH_PREFIX}identifiers`]);
283
+ /** Whether a refused session on `path` goes through `onSessionRefused`. */
284
+ function recoversRefusedSession(path) {
285
+ return !path.startsWith(AUTH_PATH_PREFIX) || SESSION_HEADER_AUTH_PATHS.has(path);
286
+ }
99
287
  var AmbaHttpClient = class {
100
288
  apiKey;
101
289
  baseUrl;
@@ -106,6 +294,12 @@ var AmbaHttpClient = class {
106
294
  deviceOs;
107
295
  identity;
108
296
  fetchImpl;
297
+ /** The `fetchImpl` option, when one was passed (`send` resolves fetch per call otherwise). */
298
+ injectedFetch;
299
+ /** Deadline for an API request, in milliseconds. */
300
+ requestTimeoutMs;
301
+ /** Deadline for AI generation, function invocations and media transfers. */
302
+ longRequestTimeoutMs;
109
303
  ensureSession;
110
304
  onSessionRefused;
111
305
  constructor(opts) {
@@ -119,7 +313,39 @@ var AmbaHttpClient = class {
119
313
  this.identity = opts.identity;
120
314
  this.ensureSession = opts.ensureSession;
121
315
  this.onSessionRefused = opts.onSessionRefused;
316
+ this.injectedFetch = opts.fetchImpl;
122
317
  this.fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
318
+ this.requestTimeoutMs = opts.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
319
+ this.longRequestTimeoutMs = opts.longRequestTimeoutMs ?? DEFAULT_LONG_REQUEST_TIMEOUT_MS;
320
+ }
321
+ /**
322
+ * Send a request to an absolute URL outside the API's JSON envelope (a
323
+ * function invocation, a CDN download, the config route that reads the
324
+ * `ETag`) under a deadline. `read` takes what the caller needs from the
325
+ * response (the body as text, a `Blob`) and runs inside the same
326
+ * deadline, so a body that stops arriving fails too.
327
+ *
328
+ * A transport failure rejects with `NETWORK_ERROR` and the message
329
+ * `<label>: <cause>`; the deadline rejects with `NETWORK_ERROR` and
330
+ * `<label>: request timed out after <ms> ms`. `label` defaults to
331
+ * `Network error` and `timeoutMs` to the request deadline; the deadline
332
+ * widens with the request body and with `responseBytes`, the size the
333
+ * caller expects to download (see `exchange`). An `AmbaApiError` that
334
+ * `read` throws passes through unchanged.
335
+ */
336
+ async send(url, init, read, opts = {}) {
337
+ const timeoutMs = opts.timeoutMs ?? this.requestTimeoutMs;
338
+ const label = opts.label ?? "Network error";
339
+ const fetchFn = this.injectedFetch ?? globalThis.fetch;
340
+ try {
341
+ return await exchange(timeoutMs, (signal) => fetchFn(url, withSignal(init, signal)), read, (ms) => timeoutError(label, ms), {
342
+ requestBytes: bodyByteLength(init.body),
343
+ responseBytes: opts.responseBytes,
344
+ fetchBuffersBody: true
345
+ });
346
+ } catch (err) {
347
+ throw asNetworkError(err, label);
348
+ }
123
349
  }
124
350
  /** Build the absolute URL for an API path, optionally with a query string. */
125
351
  url(path, query) {
@@ -179,16 +405,21 @@ var AmbaHttpClient = class {
179
405
  headers,
180
406
  body: body === void 0 ? void 0 : JSON.stringify(body ?? {})
181
407
  };
408
+ const timeoutMs = isLongApiPath(path) ? this.longRequestTimeoutMs : this.requestTimeoutMs;
182
409
  let response;
410
+ let text;
183
411
  try {
184
- response = await this.fetchImpl(url, init);
412
+ ({response, text} = await exchange(timeoutMs, (signal) => this.fetchImpl(url, withSignal(init, signal)), async (res) => ({
413
+ response: res,
414
+ text: res.status === 204 ? "" : await res.text()
415
+ }), (ms) => timeoutError("Network error", ms), { requestBytes: bodyByteLength(init.body) }));
185
416
  } catch (err) {
186
- throw new AmbaApiError("NETWORK_ERROR", `Network error: ${err instanceof Error ? err.message : typeof err === "string" ? err : "fetch failed"}`, err);
417
+ throw asNetworkError(err, "Network error");
187
418
  }
188
419
  try {
189
- return await parseResponse(response);
420
+ return parseResponse(response, text);
190
421
  } catch (err) {
191
- 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);
422
+ if (!resent && storedToken && this.onSessionRefused && recoversRefusedSession(path) && isSessionRefusedError(err) && await this.recoverSession(storedToken, storedUserId)) return this.request(method, path, body, query, void 0, true);
192
423
  throw err;
193
424
  }
194
425
  }
@@ -201,9 +432,21 @@ var AmbaHttpClient = class {
201
432
  }
202
433
  }
203
434
  };
204
- async function parseResponse(response) {
435
+ /** The `NETWORK_ERROR` a passed deadline raises. */
436
+ function timeoutError(label, timeoutMs) {
437
+ return new AmbaApiError("NETWORK_ERROR", `${label}: ${timeoutMessage(timeoutMs)}`, { timeoutMs });
438
+ }
439
+ /**
440
+ * Wrap a transport failure as `NETWORK_ERROR` (`<label>: <cause>`). An
441
+ * `AmbaApiError` (the deadline's own, or one a `send` reader throws) passes
442
+ * through unchanged.
443
+ */
444
+ function asNetworkError(err, label) {
445
+ if (err instanceof AmbaApiError) return err;
446
+ return new AmbaApiError("NETWORK_ERROR", `${label}: ${err instanceof Error ? err.message : typeof err === "string" ? err : "fetch failed"}`, err);
447
+ }
448
+ function parseResponse(response, text) {
205
449
  if (response.status === 204) return null;
206
- const text = await response.text();
207
450
  const isJson = (response.headers.get("content-type") ?? "").includes("application/json");
208
451
  if (!response.ok) throw mapHttpError(response, text, isJson);
209
452
  if (text === "") return null;
@@ -899,7 +1142,16 @@ const DETERMINISTIC_ERROR_CODES = new Set([
899
1142
  "NOT_FOUND",
900
1143
  "CONFLICT",
901
1144
  "CONSENT_NOT_GRANTED",
902
- "NOT_INITIALIZED"
1145
+ "NOT_INITIALIZED",
1146
+ "INVALID_JSON",
1147
+ "INVALID_BODY",
1148
+ "INVALID_EVENT",
1149
+ "RESERVED_EVENT_NAME",
1150
+ "INVALID_IDEMPOTENCY_KEY",
1151
+ "INVALID_TIMESTAMP",
1152
+ "INVALID_PROPERTIES",
1153
+ "INVALID_TELEMETRY",
1154
+ "IDEMPOTENCY_KEY_CONFLICT"
903
1155
  ]);
904
1156
  /**
905
1157
  * Is this a deterministic server rejection — a coded error the server would
@@ -937,13 +1189,34 @@ function isOfflineError(err) {
937
1189
  if (typeof msg === "string" && /network (error|request failed)|failed to fetch|fetch failed|load failed/i.test(msg)) return true;
938
1190
  return !isOnline();
939
1191
  }
940
- /** Generate a stable idempotency key for a queued op. */
1192
+ /**
1193
+ * Generate a stable idempotency key for a queued op: `amba_obx_` and a random
1194
+ * UUID. The server records event keys project-wide, so the UUID comes from
1195
+ * the runtime's secure random source: `crypto.randomUUID`, else
1196
+ * `crypto.getRandomValues` (Hermes has only the latter).
1197
+ */
941
1198
  function newIdempotencyKey() {
942
1199
  const c = typeof globalThis !== "undefined" ? globalThis.crypto : void 0;
943
1200
  if (c && typeof c.randomUUID === "function") return `amba_obx_${c.randomUUID()}`;
1201
+ if (c && typeof c.getRandomValues === "function") {
1202
+ const b = c.getRandomValues(new Uint8Array(16));
1203
+ b[6] = b[6] & 15 | 64;
1204
+ b[8] = b[8] & 63 | 128;
1205
+ const h = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
1206
+ return `amba_obx_${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
1207
+ }
944
1208
  return `amba_obx_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`;
945
1209
  }
946
1210
  /**
1211
+ * Whether a `track` with these properties can take the events route's keyed
1212
+ * path, which accepts a plain object or no properties. A JS caller can pass
1213
+ * something else (an array); such an event stays on the unkeyed path, which
1214
+ * stores it as it always has.
1215
+ */
1216
+ function keyableProperties(properties) {
1217
+ return properties === void 0 || properties === null || typeof properties === "object" && !Array.isArray(properties);
1218
+ }
1219
+ /**
947
1220
  * Durable, ordered, idempotent offline outbox. Opt-in via `Amba.offline`.
948
1221
  * Single in-flight flush is enforced so concurrent reconnect signals don't
949
1222
  * double-replay.
@@ -2214,6 +2487,7 @@ var CollectionSubscription = class {
2214
2487
  return openStream({
2215
2488
  url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/collections/${pathSegment(this.name)}${qs}`,
2216
2489
  authHeaders: this.ctx.authHeaders,
2490
+ connectTimeoutMs: this.ctx.connectTimeoutMs,
2217
2491
  onChange: cb,
2218
2492
  options,
2219
2493
  parse: parseRowChangeFrame
@@ -2238,6 +2512,7 @@ var ConversationSubscription = class {
2238
2512
  return openStream({
2239
2513
  url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/conversations/${pathSegment(this.conversationId)}`,
2240
2514
  authHeaders: this.ctx.authHeaders,
2515
+ connectTimeoutMs: this.ctx.connectTimeoutMs,
2241
2516
  onChange: cb,
2242
2517
  options,
2243
2518
  parse: parseRowChangeFrame
@@ -2262,6 +2537,7 @@ var GamificationSubscription = class {
2262
2537
  return openStream({
2263
2538
  url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/gamification`,
2264
2539
  authHeaders: this.ctx.authHeaders,
2540
+ connectTimeoutMs: this.ctx.connectTimeoutMs,
2265
2541
  onChange: cb,
2266
2542
  options,
2267
2543
  parse: parseGamificationFrame
@@ -2296,6 +2572,7 @@ function openStream(args) {
2296
2572
  closeActive = connectOnce({
2297
2573
  url: args.url,
2298
2574
  headers,
2575
+ connectTimeoutMs: args.connectTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
2299
2576
  onFrame: (frame) => {
2300
2577
  const ev = args.parse(frame);
2301
2578
  if (ev !== null) args.onChange(ev);
@@ -2366,8 +2643,13 @@ function connectXhr(args, XHR) {
2366
2643
  xhr.open("GET", args.url, true);
2367
2644
  for (const [k, v] of Object.entries(args.headers)) xhr.setRequestHeader(k, v);
2368
2645
  xhr.setRequestHeader("Accept", "text/event-stream");
2646
+ const connectTimer = setTimeout(() => {
2647
+ if (aborted || xhr.readyState >= 2) return;
2648
+ args.onError(/* @__PURE__ */ new Error(`realtime connection timed out after ${args.connectTimeoutMs} ms`));
2649
+ }, args.connectTimeoutMs);
2369
2650
  xhr.onreadystatechange = () => {
2370
2651
  if (aborted) return;
2652
+ if (xhr.readyState >= 2) clearTimeout(connectTimer);
2371
2653
  if (xhr.readyState === 3) {
2372
2654
  if (xhr.status >= 400) return;
2373
2655
  drain();
@@ -2391,10 +2673,12 @@ function connectXhr(args, XHR) {
2391
2673
  try {
2392
2674
  xhr.send();
2393
2675
  } catch (err) {
2676
+ clearTimeout(connectTimer);
2394
2677
  args.onError(err);
2395
2678
  }
2396
2679
  return () => {
2397
2680
  aborted = true;
2681
+ clearTimeout(connectTimer);
2398
2682
  try {
2399
2683
  xhr.abort();
2400
2684
  } catch {}
@@ -2409,14 +2693,14 @@ function connectFetchStream(args) {
2409
2693
  let aborted = false;
2410
2694
  (async () => {
2411
2695
  try {
2412
- const resp = await fetch(args.url, {
2696
+ const resp = await raceDeadline(fetch(args.url, {
2413
2697
  method: "GET",
2414
2698
  headers: {
2415
2699
  ...args.headers,
2416
2700
  Accept: "text/event-stream"
2417
2701
  },
2418
2702
  signal: controller.signal
2419
- });
2703
+ }), args.connectTimeoutMs, controller, (ms) => /* @__PURE__ */ new Error(`realtime connection timed out after ${ms} ms`));
2420
2704
  if (!resp.ok || !resp.body) throw new Error(`realtime connect failed: HTTP ${resp.status}`);
2421
2705
  const reader = resp.body.getReader();
2422
2706
  const decoder = new TextDecoder();
@@ -2496,7 +2780,7 @@ function delay(ms) {
2496
2780
  //#endregion
2497
2781
  //#region src/index.ts
2498
2782
  /** SDK semver — burned in at build time. Bump alongside package.json. */
2499
- const SDK_VERSION = "4.2.0";
2783
+ const SDK_VERSION = "4.2.2";
2500
2784
  /**
2501
2785
  * Default host that serves deployed functions. `Amba.functions.invoke(name)`
2502
2786
  * targets `https://<projectId>.<DEFAULT_FUNCTIONS_HOST>/<name>` unless a
@@ -2782,7 +3066,11 @@ function trackLink(state_, link) {
2782
3066
  }
2783
3067
  /**
2784
3068
  * After the server refused the stored session token on an authenticated
2785
- * call (`SESSION_REVOKED`, `INVALID_SESSION_TOKEN`). True when a usable
3069
+ * call (`SESSION_REVOKED`, `INVALID_SESSION_TOKEN`, `INVALID_SESSION`). The
3070
+ * HTTP client runs it for calls that send the session in the
3071
+ * `Authorization` header, `/v1/client/auth/me` and `/identifiers`
3072
+ * included; `withBodySession` runs it for the link and unlink calls, which
3073
+ * send the session in their body. True when a usable
2786
3074
  * session for the user the call was sent for is stored afterwards, so the
2787
3075
  * call is sent once more as that user:
2788
3076
  * - a link in flight on this device stores the pair it returns, which is
@@ -2816,6 +3104,40 @@ async function recoverRefusedSession(state_, usedToken, usedUserId) {
2816
3104
  return usableForCaller(state_.identity.getSessionToken());
2817
3105
  }
2818
3106
  /**
3107
+ * Send an auth call that carries the stored session token in its body
3108
+ * (`/link`, `/unlink`). `send` builds the request from the token it is
3109
+ * given. When the server refuses that token, the session recovers through
3110
+ * `recoverRefusedSession` (one refresh shared with every other refused
3111
+ * call) and the call is sent once more with the recovered token; a second
3112
+ * refusal reaches the app. A link waits here outside `trackLink`: its own
3113
+ * failed attempt has already left `linksInflight` (the removal is the first
3114
+ * reaction on that promise), so recovery never waits on itself.
3115
+ */
3116
+ async function withBodySession(state_, send) {
3117
+ const usedToken = state_.identity.getSessionToken();
3118
+ if (!usedToken) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
3119
+ const usedUserId = state_.identity.getAppUserId();
3120
+ try {
3121
+ return await send(usedToken);
3122
+ } catch (err) {
3123
+ if (!isSessionRefusedError(err)) throw err;
3124
+ if (!await recoverRefusedSession(state_, usedToken, usedUserId)) throw err;
3125
+ const recovered = state_.identity.getSessionToken();
3126
+ if (!recovered) throw err;
3127
+ return send(recovered);
3128
+ }
3129
+ }
3130
+ /**
3131
+ * Before a read of the current session (`me`, `getSession`,
3132
+ * `getCurrentIdentifiers`) while no session token is stored: wait for the
3133
+ * launch restore or sign-in in flight, so a check made at launch sees the
3134
+ * session it restores.
3135
+ */
3136
+ async function settleSessionInFlight(state_) {
3137
+ if (state_.identity.getSessionToken() || !state_.anonSignInInflight) return;
3138
+ await state_.anonSignInInflight.catch(() => {});
3139
+ }
3140
+ /**
2819
3141
  * Continue the stored session with its refresh token. True when it did.
2820
3142
  * False when there is no refresh token, or the server rejected it: the
2821
3143
  * session is then cleared (the anonymous id rotates unless the session was
@@ -2926,7 +3248,10 @@ async function refreshSessionOnce(state_) {
2926
3248
  * account's rotated anonymous id is adopted.
2927
3249
  */
2928
3250
  function postLink(state_, body) {
2929
- return trackLink(state_, postLinkOnce(state_, body));
3251
+ return withBodySession(state_, (sessionToken) => trackLink(state_, postLinkOnce(state_, {
3252
+ ...body,
3253
+ session_token: sessionToken
3254
+ })));
2930
3255
  }
2931
3256
  async function postLinkOnce(state_, body) {
2932
3257
  const env = await state_.http.post(P.authLink, {
@@ -2962,12 +3287,10 @@ async function postUserNoRotate(state_, path, body) {
2962
3287
  }
2963
3288
  async function unlinkProvider(provider) {
2964
3289
  const s = getState();
2965
- const sessionToken = s.identity.getSessionToken();
2966
- if (!sessionToken) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
2967
- return postUserNoRotate(s, P.authUnlink, {
3290
+ return withBodySession(s, (sessionToken) => postUserNoRotate(s, P.authUnlink, {
2968
3291
  provider,
2969
3292
  session_token: sessionToken
2970
- });
3293
+ }));
2971
3294
  }
2972
3295
  function requireEmail(email) {
2973
3296
  const trimmed = email.trim();
@@ -3107,9 +3430,28 @@ function offlineStore(s) {
3107
3430
  };
3108
3431
  }
3109
3432
  /**
3433
+ * Whether a live `track` (outbox on) carries its idempotency key:
3434
+ * engagement events whose properties the keyed path takes. Telemetry stays
3435
+ * on the unkeyed hot path, which writes no idempotency record and answers a
3436
+ * failed telemetry insert with 200; its replays from the outbox are keyed.
3437
+ */
3438
+ function keyedLiveTrack(properties, telemetry) {
3439
+ return telemetry !== true && keyableProperties(properties);
3440
+ }
3441
+ /**
3442
+ * A JSON copy of `properties`: exactly what a request body serializes, and
3443
+ * immune to the app changing the object after `track()` returns.
3444
+ */
3445
+ function snapshotProperties(properties) {
3446
+ if (properties === void 0 || properties === null) return void 0;
3447
+ const text = JSON.stringify(properties);
3448
+ return text === void 0 ? void 0 : JSON.parse(text);
3449
+ }
3450
+ /**
3110
3451
  * 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).
3452
+ * idempotency key on collection inserts and tracked events so a write that
3453
+ * landed before the connection dropped is never double-applied (API
3454
+ * migration 056 for collections, `client_event_idempotency` for events).
3113
3455
  */
3114
3456
  async function replayOutboxOp(s, op) {
3115
3457
  switch (op.kind) {
@@ -3131,8 +3473,9 @@ async function replayOutboxOp(s, op) {
3131
3473
  event: op.event,
3132
3474
  properties: op.properties ?? void 0,
3133
3475
  anonymous_id: s.identity.getAnonymousId(),
3134
- timestamp: op.occurredAt ?? (/* @__PURE__ */ new Date()).toISOString(),
3135
- ...op.telemetry ? { telemetry: true } : {}
3476
+ timestamp: op.occurredAt ?? op.enqueuedAt,
3477
+ ...op.telemetry ? { telemetry: true } : {},
3478
+ ...keyableProperties(op.properties) ? { idempotency_key: op.idempotencyKey } : {}
3136
3479
  });
3137
3480
  } catch (err) {
3138
3481
  const code = err?.code;
@@ -3214,6 +3557,7 @@ const Amba = {
3214
3557
  if (!Number.isInteger(flushIntervalMs) || flushIntervalMs < 0) throw new Error("Amba.configure: events.flushIntervalMs must be a non-negative integer");
3215
3558
  if (!Number.isInteger(maxQueue) || maxQueue < 1) throw new Error("Amba.configure: events.maxQueue must be positive");
3216
3559
  }
3560
+ const deadlines = resolveRequestDeadlines(config);
3217
3561
  const tokenStorage = config.tokenStorage ?? "secure";
3218
3562
  if (!TOKEN_STORAGE_MODES.includes(tokenStorage)) throw new Error("Amba.configure: tokenStorage must be \"secure\", \"memory\" or \"asyncStorage\"");
3219
3563
  const secureStorage = config.secureStorage;
@@ -3239,6 +3583,8 @@ const Amba = {
3239
3583
  deviceRegion: detectDeviceRegion(),
3240
3584
  deviceOs: detectDeviceOs(),
3241
3585
  identity,
3586
+ requestTimeoutMs: deadlines.requestMs,
3587
+ longRequestTimeoutMs: deadlines.longMs,
3242
3588
  ensureSession: config.autoAnonymousSession === false ? void 0 : async (path) => {
3243
3589
  if (pathSkipsLazySession(path)) return;
3244
3590
  await ensureAnonymousSession(getState());
@@ -3319,12 +3665,14 @@ const Amba = {
3319
3665
  },
3320
3666
  collection: (name) => new CollectionSubscription({
3321
3667
  baseUrl: getState().baseUrl,
3322
- authHeaders: realtimeAuthHeaders
3668
+ authHeaders: realtimeAuthHeaders,
3669
+ connectTimeoutMs: getState().http.requestTimeoutMs
3323
3670
  }, name),
3324
3671
  get gamification() {
3325
3672
  return new GamificationSubscription({
3326
3673
  baseUrl: getState().baseUrl,
3327
- authHeaders: realtimeAuthHeaders
3674
+ authHeaders: realtimeAuthHeaders,
3675
+ connectTimeoutMs: getState().http.requestTimeoutMs
3328
3676
  });
3329
3677
  },
3330
3678
  events: {
@@ -3332,13 +3680,16 @@ const Amba = {
3332
3680
  const s = getState();
3333
3681
  if (s.eventQueue && !options?.immediate) return s.eventQueue.enqueue(event, properties, options?.telemetry === true);
3334
3682
  const occurredAt = (/* @__PURE__ */ new Date()).toISOString();
3683
+ const props = s.offlineEnabled ? snapshotProperties(properties) : properties;
3335
3684
  const body = {
3336
3685
  event,
3337
- properties: properties ?? void 0,
3686
+ properties: props ?? void 0,
3338
3687
  anonymous_id: s.identity.getAnonymousId(),
3339
3688
  timestamp: occurredAt
3340
3689
  };
3341
3690
  if (options?.telemetry) body["telemetry"] = true;
3691
+ const idempotencyKey = s.offlineEnabled ? newIdempotencyKey() : void 0;
3692
+ if (idempotencyKey && keyedLiveTrack(props, options?.telemetry)) body["idempotency_key"] = idempotencyKey;
3342
3693
  const outboxTicket = s.offlineEnabled ? getOfflineQueue(s).ticket() : void 0;
3343
3694
  try {
3344
3695
  await s.http.post(P.events, body);
@@ -3347,9 +3698,10 @@ const Amba = {
3347
3698
  await getOfflineQueue(s).enqueue({
3348
3699
  kind: "track",
3349
3700
  event,
3350
- properties,
3701
+ properties: props,
3351
3702
  telemetry: options?.telemetry,
3352
- occurredAt
3703
+ occurredAt,
3704
+ idempotencyKey
3353
3705
  }, outboxTicket);
3354
3706
  return;
3355
3707
  }
@@ -3452,52 +3804,38 @@ const Amba = {
3452
3804
  if (providerTrimmed === "") throw new AmbaApiError("VALIDATION_ERROR", "provider must not be empty");
3453
3805
  if (typeof credential !== "string" || credential.trim() === "") throw new AmbaApiError("VALIDATION_ERROR", "credential must not be empty");
3454
3806
  const s = getState();
3455
- const sessionToken = s.identity.getSessionToken();
3456
- if (!sessionToken) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
3457
- return trackLink(s, postAuth(s, P.authLink, {
3807
+ return withBodySession(s, (sessionToken) => trackLink(s, postAuth(s, P.authLink, {
3458
3808
  provider: providerTrimmed,
3459
3809
  token: credential,
3460
3810
  session_token: sessionToken,
3461
3811
  ...nonceField(options)
3462
- }));
3812
+ })));
3463
3813
  },
3464
3814
  linkSmsOtp: async (phone, code) => {
3465
3815
  requireE164Phone(phone);
3466
3816
  requireOtpCode(code);
3467
- const s = getState();
3468
- const sessionToken = s.identity.getSessionToken();
3469
- if (!sessionToken) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
3470
- return postLink(s, {
3817
+ return postLink(getState(), {
3471
3818
  provider: "sms_otp",
3472
3819
  phone,
3473
- code,
3474
- session_token: sessionToken
3820
+ code
3475
3821
  });
3476
3822
  },
3477
3823
  linkEmailOtp: async (email, code) => {
3478
3824
  requireEmail(email);
3479
3825
  requireOtpCode(code);
3480
- const s = getState();
3481
- const sessionToken = s.identity.getSessionToken();
3482
- if (!sessionToken) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
3483
- return postLink(s, {
3826
+ return postLink(getState(), {
3484
3827
  provider: "email_otp",
3485
3828
  email,
3486
- code,
3487
- session_token: sessionToken
3829
+ code
3488
3830
  });
3489
3831
  },
3490
3832
  linkEmailPassword: async (email, password) => {
3491
3833
  requireEmail(email);
3492
3834
  requirePassword(password);
3493
- const s = getState();
3494
- const sessionToken = s.identity.getSessionToken();
3495
- if (!sessionToken) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
3496
- return postLink(s, {
3835
+ return postLink(getState(), {
3497
3836
  provider: "email_password",
3498
3837
  email,
3499
- password,
3500
- session_token: sessionToken
3838
+ password
3501
3839
  });
3502
3840
  },
3503
3841
  unlinkSmsOtp: async () => unlinkProvider("sms_otp"),
@@ -3506,6 +3844,7 @@ const Amba = {
3506
3844
  unlinkGoogle: async () => unlinkProvider("google"),
3507
3845
  getCurrentIdentifiers: async () => {
3508
3846
  const s = getState();
3847
+ await settleSessionInFlight(s);
3509
3848
  if (!s.identity.getSessionToken()) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
3510
3849
  return (await s.http.get(P.authIdentifiers)).data.identifiers;
3511
3850
  },
@@ -3532,13 +3871,20 @@ const Amba = {
3532
3871
  refresh: async () => refreshSession(getState()),
3533
3872
  me: async () => {
3534
3873
  const s = getState();
3874
+ await settleSessionInFlight(s);
3535
3875
  if (!s.identity.getSessionToken()) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
3536
3876
  return normalizeUserAuthProviders((await s.http.get(P.authMe)).data);
3537
3877
  },
3538
3878
  getSession: async () => {
3539
3879
  const s = getState();
3880
+ await settleSessionInFlight(s);
3540
3881
  if (!s.identity.isAuthenticated()) return null;
3541
- return snapshotSession(normalizeUserAuthProviders((await s.http.get(P.authMe)).data));
3882
+ try {
3883
+ return snapshotSession(normalizeUserAuthProviders((await s.http.get(P.authMe)).data));
3884
+ } catch (err) {
3885
+ if (isSessionRefusedError(err) && !s.identity.isAuthenticated()) return null;
3886
+ throw err;
3887
+ }
3542
3888
  },
3543
3889
  getAnonymousId: async () => {
3544
3890
  return getState().identity.getAnonymousId();
@@ -3811,21 +4157,25 @@ const Amba = {
3811
4157
  await getState().http.delete(`${P.storage}/${encodePathSegment(assetId)}`);
3812
4158
  },
3813
4159
  download: async (assetId) => {
3814
- const assetEnv = await getState().http.get(`${P.storage}/${encodePathSegment(assetId)}`);
4160
+ const s = getState();
4161
+ const assetEnv = await s.http.get(`${P.storage}/${encodePathSegment(assetId)}`);
3815
4162
  const cdnUrl = assetEnv.data.url ?? assetEnv.data.storage_url ?? "";
3816
4163
  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();
4164
+ const sizeBytes = assetEnv.data.size_bytes;
4165
+ const { response, blob, text } = await s.http.send(cdnUrl, { method: "GET" }, async (res) => res.ok ? {
4166
+ response: res,
4167
+ blob: await res.blob(),
4168
+ text: ""
4169
+ } : {
4170
+ response: res,
4171
+ blob: null,
4172
+ text: await res.text()
4173
+ }, {
4174
+ timeoutMs: s.http.longRequestTimeoutMs,
4175
+ responseBytes: typeof sizeBytes === "number" ? sizeBytes : void 0
4176
+ });
4177
+ if (!blob) throw mapHttpError(response, text, (response.headers.get("content-type") ?? "").includes("application/json"));
4178
+ return blob;
3829
4179
  }
3830
4180
  },
3831
4181
  push: {
@@ -3873,27 +4223,30 @@ const Amba = {
3873
4223
  functions: { invoke: async (name, body, options) => {
3874
4224
  const s = getState();
3875
4225
  if (!name || name.trim() === "") throw new AmbaApiError("INVALID_ARGUMENT", "Amba.functions.invoke: a function name is required.");
4226
+ let timeoutMs;
4227
+ try {
4228
+ timeoutMs = readTimeout("Amba.functions.invoke: timeoutMs", options?.timeoutMs, s.http.longRequestTimeoutMs);
4229
+ } catch (err) {
4230
+ throw new AmbaApiError("INVALID_ARGUMENT", err.message);
4231
+ }
3876
4232
  const url = await resolveFunctionUrl(s, name);
3877
4233
  const method = options?.method ?? "POST";
3878
4234
  const headers = s.http.buildHeaders(options?.headers);
3879
4235
  let requestBody;
3880
4236
  if (body !== void 0 && method !== "GET") requestBody = JSON.stringify(body);
3881
4237
  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
- }
4238
+ const { response, text } = await s.http.send(url, {
4239
+ method,
4240
+ headers,
4241
+ body: requestBody
4242
+ }, async (res) => ({
4243
+ response: res,
4244
+ text: await res.text()
4245
+ }), {
4246
+ timeoutMs,
4247
+ label: `Amba.functions.invoke("${name}") network error`
4248
+ });
4249
+ if (!response.ok) throw mapHttpError(response, text, (response.headers.get("content-type") ?? "").includes("application/json"));
3897
4250
  if (!text) return void 0;
3898
4251
  try {
3899
4252
  return JSON.parse(text);
@@ -3907,20 +4260,14 @@ const Amba = {
3907
4260
  const ticket = s.remoteValues.ticket();
3908
4261
  const url = s.http.url(P.config);
3909
4262
  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
- }
4263
+ const { response, text } = await s.http.send(url, {
4264
+ method: "GET",
4265
+ headers
4266
+ }, async (res) => ({
4267
+ response: res,
4268
+ text: await res.text()
4269
+ }));
4270
+ if (!response.ok) throw mapHttpError(response, text, (response.headers.get("content-type") ?? "").includes("application/json"));
3924
4271
  let parsed = {};
3925
4272
  if (text) try {
3926
4273
  parsed = JSON.parse(text);
@@ -4143,7 +4490,8 @@ const Amba = {
4143
4490
  messaging: {
4144
4491
  conversation: (conversationId) => new ConversationSubscription({
4145
4492
  baseUrl: getState().baseUrl,
4146
- authHeaders: realtimeAuthHeaders
4493
+ authHeaders: realtimeAuthHeaders,
4494
+ connectTimeoutMs: getState().http.requestTimeoutMs
4147
4495
  }, conversationId),
4148
4496
  createConversation: async (request) => {
4149
4497
  return (await getState().http.post(`${P.messaging}/conversations`, request)).data;